Skip to main content

player_cli/
ios_bridge_shim.rs

1use std::{
2    collections::{BTreeSet, HashMap, HashSet},
3    fs::{self, File},
4    io::Read,
5    path::{Component, Path, PathBuf},
6};
7
8use anyhow::{Context, Result, bail};
9use serde::{Deserialize, Serialize};
10
11const GENERATED_NOTICE: &str = "Auto-generated. Do not edit directly.";
12pub const BRIDGE_SHIM_HEADER_FILE: &str = "include/VesperPlayerKitBridgeShim.h";
13pub const BRIDGE_SHIM_SOURCE_FILE: &str = "VesperPlayerKitBridgeShim.c";
14const MAX_BRIDGE_MANIFEST_BYTES: usize = 4 * 1024 * 1024;
15const MAX_BRIDGE_FRAGMENT_BYTES: usize = 1024 * 1024;
16const MAX_BRIDGE_SOURCE_ITEMS: usize = 512;
17const MAX_BRIDGE_DECLARATIONS: usize = 1024;
18const MAX_BRIDGE_INCLUDES: usize = 256;
19const MAX_BRIDGE_FFI_SYMBOLS_PER_WRAPPER: usize = 64;
20const MAX_GENERATED_BRIDGE_HEADER_BYTES: usize = 4 * 1024 * 1024;
21const MAX_GENERATED_BRIDGE_SOURCE_BYTES: usize = 16 * 1024 * 1024;
22
23#[derive(Debug, Deserialize, Serialize)]
24struct Manifest {
25    header_guard: String,
26    header_includes: Vec<String>,
27    c_includes: Vec<String>,
28    public_declarations: Vec<Declaration>,
29    private_declarations: Vec<Declaration>,
30    source_items: Vec<SourceItem>,
31}
32
33#[derive(Debug, Clone, Deserialize, Serialize)]
34#[serde(tag = "kind")]
35enum Declaration {
36    #[serde(rename = "enum")]
37    Enum {
38        name: String,
39        variants: Vec<EnumVariant>,
40    },
41    #[serde(rename = "struct")]
42    Struct { name: String, fields: Vec<Field> },
43    #[serde(rename = "function")]
44    Function {
45        return_type: String,
46        name: String,
47        parameters: Vec<Parameter>,
48        #[serde(default)]
49        storage: FunctionStorage,
50    },
51}
52
53#[derive(Debug, Clone, Default, Deserialize, Serialize)]
54#[serde(rename_all = "snake_case")]
55enum FunctionStorage {
56    #[default]
57    Public,
58    Extern,
59}
60
61#[derive(Debug, Clone, Deserialize, Serialize)]
62struct EnumVariant {
63    name: String,
64    value: i64,
65}
66
67#[derive(Debug, Clone, Deserialize, Serialize)]
68struct Field {
69    ty: String,
70    name: String,
71}
72
73#[derive(Debug, Clone, Deserialize, Serialize)]
74struct Parameter {
75    ty: String,
76    name: String,
77}
78
79#[derive(Debug, Deserialize, Serialize)]
80#[serde(tag = "kind")]
81enum SourceItem {
82    #[serde(rename = "static_fragment")]
83    StaticFragment { path: String },
84    #[serde(rename = "wrapper")]
85    Wrapper {
86        function: String,
87        ffi_symbols: Vec<String>,
88        ownership: Vec<String>,
89        body_fragment: String,
90    },
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct GeneratedShim {
95    header: String,
96    source: String,
97    required_ffi_symbols: Vec<String>,
98}
99
100impl GeneratedShim {
101    pub fn header(&self) -> &str {
102        &self.header
103    }
104
105    pub fn source(&self) -> &str {
106        &self.source
107    }
108
109    pub fn required_ffi_symbols(&self) -> &[String] {
110        &self.required_ffi_symbols
111    }
112}
113
114struct Cli {
115    command: Command,
116    manifest_path: PathBuf,
117    output_dir: PathBuf,
118    source_header_path: Option<PathBuf>,
119    source_c_path: Option<PathBuf>,
120}
121
122enum Command {
123    Generate,
124    Bootstrap,
125}
126
127pub fn run_cli(args: impl IntoIterator<Item = String>) -> Result<()> {
128    let cli = Cli::parse(args)?;
129    match cli.command {
130        Command::Generate => {
131            let generated = generate_from_manifest(&cli.manifest_path)?;
132            write_generated_directory(&cli.output_dir, &generated)?;
133        }
134        Command::Bootstrap => {
135            let header_path = cli
136                .source_header_path
137                .as_deref()
138                .context("bootstrap requires --source-header")?;
139            let c_path = cli
140                .source_c_path
141                .as_deref()
142                .context("bootstrap requires --source-c")?;
143            bootstrap_from_sources(&cli.manifest_path, &cli.output_dir, header_path, c_path)?;
144        }
145    }
146    Ok(())
147}
148
149impl Cli {
150    fn parse(args: impl IntoIterator<Item = String>) -> Result<Self> {
151        let mut command = None;
152        let mut manifest_path = None;
153        let mut output_dir = None;
154        let mut source_header_path = None;
155        let mut source_c_path = None;
156
157        let mut args = args.into_iter();
158        while let Some(arg) = args.next() {
159            match arg.as_str() {
160                "generate" => command = Some(Command::Generate),
161                "bootstrap" => command = Some(Command::Bootstrap),
162                "--manifest" => manifest_path = Some(next_path(&mut args, "--manifest")?),
163                "--out-dir" => output_dir = Some(next_path(&mut args, "--out-dir")?),
164                "--source-header" => {
165                    source_header_path = Some(next_path(&mut args, "--source-header")?)
166                }
167                "--source-c" => source_c_path = Some(next_path(&mut args, "--source-c")?),
168                "-h" | "--help" => {
169                    print_usage();
170                    std::process::exit(0);
171                }
172                _ => bail!("unknown argument: {arg}"),
173            }
174        }
175
176        Ok(Self {
177            command: command.context("missing command: generate or bootstrap")?,
178            manifest_path: manifest_path.context("missing --manifest")?,
179            output_dir: output_dir.context("missing --out-dir")?,
180            source_header_path,
181            source_c_path,
182        })
183    }
184}
185
186fn next_path(args: &mut impl Iterator<Item = String>, flag: &str) -> Result<PathBuf> {
187    Ok(PathBuf::from(
188        args.next()
189            .with_context(|| format!("{flag} requires a value"))?,
190    ))
191}
192
193fn print_usage() {
194    eprintln!(
195        "Usage:\n  player-ios-bridge-shim-generator generate --manifest <path> --out-dir <dir>\n  player-ios-bridge-shim-generator bootstrap --manifest <path> --out-dir <fragment-dir> --source-header <path> --source-c <path>"
196    );
197}
198
199fn manifest_base_dir(manifest_path: &Path) -> PathBuf {
200    manifest_path
201        .parent()
202        .map_or_else(|| PathBuf::from("."), Path::to_path_buf)
203}
204
205fn validate_manifest(manifest: &Manifest) -> Result<()> {
206    if manifest.header_includes.len() > MAX_BRIDGE_INCLUDES
207        || manifest.c_includes.len() > MAX_BRIDGE_INCLUDES
208    {
209        bail!("bridge manifest exceeds {MAX_BRIDGE_INCLUDES} includes per generated file");
210    }
211    if manifest.public_declarations.len() > MAX_BRIDGE_DECLARATIONS
212        || manifest.private_declarations.len() > MAX_BRIDGE_DECLARATIONS
213    {
214        bail!("bridge manifest exceeds {MAX_BRIDGE_DECLARATIONS} declarations per section");
215    }
216    if manifest.source_items.len() > MAX_BRIDGE_SOURCE_ITEMS {
217        bail!("bridge manifest exceeds {MAX_BRIDGE_SOURCE_ITEMS} source items");
218    }
219
220    let mut public_functions = HashSet::new();
221    for declaration in &manifest.public_declarations {
222        if let Declaration::Function { name, .. } = declaration
223            && !public_functions.insert(name.as_str())
224        {
225            bail!("bridge manifest contains duplicate public function: {name}");
226        }
227    }
228    let mut wrapper_functions = HashSet::new();
229    for item in &manifest.source_items {
230        if let SourceItem::Wrapper {
231            function,
232            ffi_symbols,
233            ..
234        } = item
235        {
236            if ffi_symbols.len() > MAX_BRIDGE_FFI_SYMBOLS_PER_WRAPPER {
237                bail!(
238                    "bridge wrapper {function} exceeds {MAX_BRIDGE_FFI_SYMBOLS_PER_WRAPPER} FFI symbols"
239                );
240            }
241            if !wrapper_functions.insert(function.as_str()) {
242                bail!("bridge manifest contains duplicate wrapper: {function}");
243            }
244        }
245    }
246    if let Some(function) = public_functions.difference(&wrapper_functions).next() {
247        bail!("bridge public function has no wrapper source item: {function}");
248    }
249    Ok(())
250}
251
252fn read_bounded_utf8_file(path: &Path, maximum_bytes: usize, label: &str) -> Result<String> {
253    let path_metadata = fs::symlink_metadata(path)
254        .with_context(|| format!("failed to inspect {label}: {}", path.display()))?;
255    if !path_metadata.file_type().is_file() {
256        bail!(
257            "{label} is not a regular non-symlink file: {}",
258            path.display()
259        );
260    }
261    let mut file =
262        File::open(path).with_context(|| format!("failed to open {label}: {}", path.display()))?;
263    let metadata = file
264        .metadata()
265        .with_context(|| format!("failed to inspect opened {label}: {}", path.display()))?;
266    if !metadata.is_file() {
267        bail!("{label} is not a regular file: {}", path.display());
268    }
269    if metadata.len() > maximum_bytes as u64 {
270        bail!("{label} exceeds {maximum_bytes} bytes: {}", path.display());
271    }
272    let mut bytes = Vec::with_capacity(metadata.len() as usize);
273    file.by_ref()
274        .take((maximum_bytes + 1) as u64)
275        .read_to_end(&mut bytes)
276        .with_context(|| format!("failed to read {label}: {}", path.display()))?;
277    if bytes.len() > maximum_bytes {
278        bail!("{label} exceeds {maximum_bytes} bytes: {}", path.display());
279    }
280    String::from_utf8(bytes).with_context(|| format!("{label} is not UTF-8: {}", path.display()))
281}
282
283fn read_bridge_fragment(manifest_dir: &Path, configured_path: &str, label: &str) -> Result<String> {
284    let mut relative = PathBuf::new();
285    for component in Path::new(configured_path).components() {
286        match component {
287            Component::Normal(value) => relative.push(value),
288            Component::CurDir => {}
289            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
290                bail!(
291                    "{label} must use a relative fragment path without parent traversal: {configured_path}"
292                );
293            }
294        }
295    }
296    if relative.as_os_str().is_empty() {
297        bail!("{label} must use a non-empty relative fragment path");
298    }
299
300    let base = fs::canonicalize(manifest_dir).with_context(|| {
301        format!(
302            "failed to resolve bridge manifest directory: {}",
303            manifest_dir.display()
304        )
305    })?;
306    let base_metadata = fs::symlink_metadata(&base).with_context(|| {
307        format!(
308            "failed to inspect bridge manifest directory: {}",
309            base.display()
310        )
311    })?;
312    if !base_metadata.file_type().is_dir() {
313        bail!(
314            "bridge manifest directory is not a regular non-symlink directory: {}",
315            base.display()
316        );
317    }
318
319    let component_count = relative.components().count();
320    let mut current = base;
321    for (index, component) in relative.components().enumerate() {
322        let Component::Normal(value) = component else {
323            continue;
324        };
325        current.push(value);
326        let metadata = fs::symlink_metadata(&current)
327            .with_context(|| format!("failed to inspect {label}: {}", current.display()))?;
328        if index + 1 == component_count {
329            if !metadata.file_type().is_file() {
330                bail!(
331                    "{label} is not a regular non-symlink file: {}",
332                    current.display()
333                );
334            }
335        } else if !metadata.file_type().is_dir() {
336            bail!(
337                "{label} parent is not a regular non-symlink directory: {}",
338                current.display()
339            );
340        }
341    }
342    read_bounded_utf8_file(&current, MAX_BRIDGE_FRAGMENT_BYTES, label)
343}
344
345fn read_manifest(path: &Path) -> Result<Manifest> {
346    let content = read_bounded_utf8_file(path, MAX_BRIDGE_MANIFEST_BYTES, "bridge manifest")?;
347    let manifest = serde_json::from_str(&content)
348        .with_context(|| format!("failed to parse manifest: {}", path.display()))?;
349    validate_manifest(&manifest)?;
350    Ok(manifest)
351}
352
353pub fn write_generated_directory(output_dir: &Path, generated: &GeneratedShim) -> Result<()> {
354    let include_dir = output_dir.join("include");
355    fs::create_dir_all(&include_dir).with_context(|| {
356        format!(
357            "failed to create output include dir: {}",
358            include_dir.display()
359        )
360    })?;
361    fs::write(output_dir.join(BRIDGE_SHIM_HEADER_FILE), &generated.header)
362        .with_context(|| "failed to write generated bridge shim header")?;
363    fs::write(output_dir.join(BRIDGE_SHIM_SOURCE_FILE), &generated.source)
364        .with_context(|| "failed to write generated bridge shim source")?;
365    Ok(())
366}
367
368pub fn generate_from_manifest(manifest_path: &Path) -> Result<GeneratedShim> {
369    let manifest = read_manifest(manifest_path)?;
370    generate(&manifest, manifest_base_dir(manifest_path).as_path())
371}
372
373fn generate(manifest: &Manifest, manifest_dir: &Path) -> Result<GeneratedShim> {
374    let header = generate_header(manifest)?;
375    let source = generate_source(manifest, manifest_dir)?;
376    let declared_ffi_symbols = manifest
377        .source_items
378        .iter()
379        .filter_map(|item| match item {
380            SourceItem::Wrapper { ffi_symbols, .. } => Some(ffi_symbols),
381            SourceItem::StaticFragment { .. } => None,
382        })
383        .flatten()
384        .cloned()
385        .collect::<BTreeSet<_>>();
386    let referenced_ffi_symbols = sorted_player_ffi_symbols(&source)
387        .into_iter()
388        .collect::<BTreeSet<_>>();
389    let missing_from_manifest = referenced_ffi_symbols
390        .difference(&declared_ffi_symbols)
391        .cloned()
392        .collect::<Vec<_>>();
393    let missing_from_generated_source = declared_ffi_symbols
394        .difference(&referenced_ffi_symbols)
395        .cloned()
396        .collect::<Vec<_>>();
397    if !missing_from_manifest.is_empty() || !missing_from_generated_source.is_empty() {
398        let mut details = Vec::new();
399        if !missing_from_manifest.is_empty() {
400            details.push(format!(
401                "missing from manifest ffi_symbols: {}",
402                missing_from_manifest.join(", ")
403            ));
404        }
405        if !missing_from_generated_source.is_empty() {
406            details.push(format!(
407                "declared but absent from generated C: {}",
408                missing_from_generated_source.join(", ")
409            ));
410        }
411        bail!(
412            "bridge manifest ffi_symbols do not match generated C references; {}",
413            details.join("; ")
414        );
415    }
416    let required_ffi_symbols = referenced_ffi_symbols.into_iter().collect();
417    Ok(GeneratedShim {
418        header,
419        source,
420        required_ffi_symbols,
421    })
422}
423
424fn generate_header(manifest: &Manifest) -> Result<String> {
425    let mut output = String::new();
426    output.push_str("/* ");
427    output.push_str(GENERATED_NOTICE);
428    output.push_str(" */\n");
429    output.push_str("#ifndef ");
430    output.push_str(&manifest.header_guard);
431    output.push('\n');
432    output.push_str("#define ");
433    output.push_str(&manifest.header_guard);
434    output.push_str("\n\n");
435    push_includes(&mut output, &manifest.header_includes);
436    output.push('\n');
437    push_declarations(&mut output, &manifest.public_declarations)?;
438    output.push_str("#endif\n");
439    if output.len() > MAX_GENERATED_BRIDGE_HEADER_BYTES {
440        bail!("generated bridge header exceeds {MAX_GENERATED_BRIDGE_HEADER_BYTES} bytes");
441    }
442    Ok(output)
443}
444
445fn generate_source(manifest: &Manifest, manifest_dir: &Path) -> Result<String> {
446    let mut output = String::new();
447    output.push_str("/* ");
448    output.push_str(GENERATED_NOTICE);
449    output.push_str(" */\n");
450    push_includes(&mut output, &manifest.c_includes);
451    output.push('\n');
452    push_declarations(&mut output, &manifest.private_declarations)?;
453    let public_functions = public_function_declarations(&manifest.public_declarations);
454    for source_item in &manifest.source_items {
455        match source_item {
456            SourceItem::StaticFragment { path } => {
457                let fragment_content =
458                    read_bridge_fragment(manifest_dir, path, "C static fragment")?;
459                if !output.ends_with("\n\n") {
460                    output.push('\n');
461                }
462                output.push_str(fragment_content.trim_end());
463                output.push('\n');
464            }
465            SourceItem::Wrapper {
466                function,
467                ffi_symbols,
468                ownership: _,
469                body_fragment,
470            } => {
471                let declaration = public_functions.get(function.as_str()).with_context(|| {
472                    format!("wrapper fragment references unknown public function: {function}")
473                })?;
474                let body =
475                    read_bridge_fragment(manifest_dir, body_fragment, "wrapper body fragment")?;
476                for ffi_symbol in ffi_symbols {
477                    let call_pattern = format!("{ffi_symbol}(");
478                    if !body.contains(&call_pattern) {
479                        bail!(
480                            "wrapper {function} declares FFI symbol {ffi_symbol}, but its body fragment does not call it"
481                        );
482                    }
483                }
484                if !output.ends_with("\n\n") {
485                    output.push('\n');
486                }
487                push_function_definition_signature(
488                    &mut output,
489                    declaration.return_type,
490                    declaration.name,
491                    declaration.parameters,
492                );
493                output.push(' ');
494                output.push_str(body.trim());
495                output.push('\n');
496            }
497        }
498        if output.len() > MAX_GENERATED_BRIDGE_SOURCE_BYTES {
499            bail!("generated bridge source exceeds {MAX_GENERATED_BRIDGE_SOURCE_BYTES} bytes");
500        }
501    }
502    Ok(output)
503}
504
505struct FunctionDeclaration<'a> {
506    return_type: &'a str,
507    name: &'a str,
508    parameters: &'a [Parameter],
509}
510
511fn public_function_declarations(
512    declarations: &[Declaration],
513) -> HashMap<&str, FunctionDeclaration<'_>> {
514    declarations
515        .iter()
516        .filter_map(|declaration| match declaration {
517            Declaration::Function {
518                return_type,
519                name,
520                parameters,
521                ..
522            } => Some((
523                name.as_str(),
524                FunctionDeclaration {
525                    return_type,
526                    name,
527                    parameters,
528                },
529            )),
530            _ => None,
531        })
532        .collect()
533}
534
535fn push_includes(output: &mut String, includes: &[String]) {
536    for include in includes {
537        output.push_str("#include ");
538        output.push_str(include);
539        output.push('\n');
540    }
541}
542
543fn push_declarations(output: &mut String, declarations: &[Declaration]) -> Result<()> {
544    for declaration in declarations {
545        push_declaration(output, declaration)?;
546        output.push('\n');
547    }
548    Ok(())
549}
550
551fn push_declaration(output: &mut String, declaration: &Declaration) -> Result<()> {
552    match declaration {
553        Declaration::Enum { name, variants } => {
554            output.push_str("typedef enum ");
555            output.push_str(name);
556            output.push_str(" {\n");
557            for variant in variants {
558                output.push_str("  ");
559                output.push_str(&variant.name);
560                output.push_str(" = ");
561                output.push_str(&variant.value.to_string());
562                output.push_str(",\n");
563            }
564            output.push_str("} ");
565            output.push_str(name);
566            output.push_str(";\n");
567        }
568        Declaration::Struct { name, fields } => {
569            output.push_str("typedef struct ");
570            output.push_str(name);
571            output.push_str(" {\n");
572            for field in fields {
573                output.push_str("  ");
574                push_typed_name(output, &field.ty, &field.name);
575                output.push_str(";\n");
576            }
577            output.push_str("} ");
578            output.push_str(name);
579            output.push_str(";\n");
580        }
581        Declaration::Function {
582            return_type,
583            name,
584            parameters,
585            storage,
586        } => {
587            push_function_declaration(output, return_type, name, parameters, storage)?;
588        }
589    }
590    Ok(())
591}
592
593fn push_function_declaration(
594    output: &mut String,
595    return_type: &str,
596    name: &str,
597    parameters: &[Parameter],
598    storage: &FunctionStorage,
599) -> Result<()> {
600    if matches!(storage, FunctionStorage::Extern) {
601        output.push_str("extern ");
602    }
603    if parameters.is_empty() {
604        output.push_str(return_type);
605        output.push(' ');
606        output.push_str(name);
607        output.push_str("(void);\n");
608        return Ok(());
609    }
610
611    if parameters.len() == 1 {
612        output.push_str(return_type);
613        output.push(' ');
614        output.push_str(name);
615        output.push('(');
616        push_typed_name(output, &parameters[0].ty, &parameters[0].name);
617        output.push_str(");\n");
618        return Ok(());
619    }
620
621    output.push_str(return_type);
622    output.push(' ');
623    output.push_str(name);
624    output.push_str("(\n");
625    for (index, parameter) in parameters.iter().enumerate() {
626        output.push_str("    ");
627        push_typed_name(output, &parameter.ty, &parameter.name);
628        if index + 1 == parameters.len() {
629            output.push_str(");\n");
630        } else {
631            output.push_str(",\n");
632        }
633    }
634    Ok(())
635}
636
637fn push_function_definition_signature(
638    output: &mut String,
639    return_type: &str,
640    name: &str,
641    parameters: &[Parameter],
642) {
643    if parameters.is_empty() {
644        output.push_str(return_type);
645        output.push(' ');
646        output.push_str(name);
647        output.push_str("(void)");
648        return;
649    }
650
651    if parameters.len() == 1 {
652        output.push_str(return_type);
653        output.push(' ');
654        output.push_str(name);
655        output.push('(');
656        push_typed_name(output, &parameters[0].ty, &parameters[0].name);
657        output.push(')');
658        return;
659    }
660
661    output.push_str(return_type);
662    output.push(' ');
663    output.push_str(name);
664    output.push_str("(\n");
665    for (index, parameter) in parameters.iter().enumerate() {
666        output.push_str("    ");
667        push_typed_name(output, &parameter.ty, &parameter.name);
668        if index + 1 == parameters.len() {
669            output.push(')');
670        } else {
671            output.push_str(",\n");
672        }
673    }
674}
675
676fn push_typed_name(output: &mut String, ty: &str, name: &str) {
677    if let Some(marker) = ty.find("(*)") {
678        output.push_str(&ty[..marker + 2]);
679        output.push_str(name);
680        output.push_str(&ty[marker + 2..]);
681    } else {
682        output.push_str(ty);
683        if !ty.ends_with('*') {
684            output.push(' ');
685        }
686        output.push_str(name);
687    }
688}
689
690pub fn bootstrap_from_sources(
691    manifest_path: &Path,
692    fragment_dir: &Path,
693    header_path: &Path,
694    c_path: &Path,
695) -> Result<()> {
696    fs::create_dir_all(fragment_dir)
697        .with_context(|| format!("failed to create fragment dir: {}", fragment_dir.display()))?;
698
699    let header = fs::read_to_string(header_path)
700        .with_context(|| format!("failed to read source header: {}", header_path.display()))?;
701    let source = fs::read_to_string(c_path)
702        .with_context(|| format!("failed to read source C file: {}", c_path.display()))?;
703
704    let public_declarations = parse_header_declarations(&header)?;
705    let (c_includes, private_declarations, body) = parse_source_sections(&source)?;
706    let public_function_names = public_declarations
707        .iter()
708        .filter_map(|declaration| match declaration {
709            Declaration::Function { name, .. } => Some(name.as_str()),
710            _ => None,
711        })
712        .collect::<HashSet<_>>();
713    let source_items = split_body_fragments(&body, &public_function_names, fragment_dir)?;
714
715    let manifest = Manifest {
716        header_guard: "VESPER_PLAYER_KIT_BRIDGE_SHIM_H".to_string(),
717        header_includes: vec![
718            "<stdbool.h>".to_string(),
719            "<stddef.h>".to_string(),
720            "<stdint.h>".to_string(),
721        ],
722        c_includes,
723        public_declarations,
724        private_declarations,
725        source_items,
726    };
727    let manifest_content = serde_json::to_string_pretty(&manifest)?;
728    fs::write(manifest_path, format!("{manifest_content}\n"))
729        .with_context(|| format!("failed to write manifest: {}", manifest_path.display()))?;
730    Ok(())
731}
732
733fn split_body_fragments(
734    body: &str,
735    public_function_names: &HashSet<&str>,
736    fragment_dir: &Path,
737) -> Result<Vec<SourceItem>> {
738    let wrapper_dir = fragment_dir.join("fragments/wrappers");
739    fs::create_dir_all(&wrapper_dir).with_context(|| {
740        format!(
741            "failed to create wrapper fragment dir: {}",
742            wrapper_dir.display()
743        )
744    })?;
745    let static_dir = fragment_dir.join("fragments/static");
746    fs::create_dir_all(&static_dir).with_context(|| {
747        format!(
748            "failed to create static fragment dir: {}",
749            static_dir.display()
750        )
751    })?;
752
753    let mut cursor = 0;
754    let mut static_group = String::new();
755    let mut static_group_index = 0usize;
756    let mut source_items = Vec::new();
757    while let Some(item_start) = next_non_whitespace(body, cursor) {
758        let open_brace = find_top_level_open_brace(body, item_start)?;
759        let item_end = find_matching_brace(body, open_brace)? + 1;
760        let item = &body[item_start..item_end];
761        let signature = body[item_start..open_brace].trim();
762        let function_name = top_level_function_name(signature)?;
763        if signature.starts_with("static ") {
764            if !static_group.is_empty() {
765                static_group.push_str("\n\n");
766            }
767            static_group.push_str(item.trim());
768        } else if public_function_names.contains(function_name.as_str()) {
769            flush_static_group(
770                fragment_dir,
771                &mut source_items,
772                &mut static_group,
773                &mut static_group_index,
774            )?;
775            let body_fragment = &body[open_brace..item_end];
776            let fragment_name = format!("fragments/wrappers/{function_name}.c.inc");
777            let fragment_path = fragment_dir.join(&fragment_name);
778            fs::write(&fragment_path, format!("{}\n", body_fragment.trim())).with_context(
779                || {
780                    format!(
781                        "failed to write wrapper fragment: {}",
782                        fragment_path.display()
783                    )
784                },
785            )?;
786            source_items.push(SourceItem::Wrapper {
787                function: function_name,
788                ffi_symbols: sorted_player_ffi_symbols(body_fragment),
789                ownership: ownership_notes(body_fragment),
790                body_fragment: fragment_name,
791            });
792        } else {
793            bail!("unknown non-static bridge function in C body: {function_name}");
794        }
795        cursor = item_end;
796    }
797    flush_static_group(
798        fragment_dir,
799        &mut source_items,
800        &mut static_group,
801        &mut static_group_index,
802    )?;
803
804    Ok(source_items)
805}
806
807fn flush_static_group(
808    fragment_dir: &Path,
809    source_items: &mut Vec<SourceItem>,
810    static_group: &mut String,
811    static_group_index: &mut usize,
812) -> Result<()> {
813    if static_group.trim().is_empty() {
814        return Ok(());
815    }
816    *static_group_index += 1;
817    let fragment_name = format!("fragments/static/static-helpers-{static_group_index:02}.c.inc");
818    let fragment_path = fragment_dir.join(&fragment_name);
819    fs::write(&fragment_path, format!("{}\n", static_group.trim())).with_context(|| {
820        format!(
821            "failed to write static helper fragment: {}",
822            fragment_path.display()
823        )
824    })?;
825    source_items.push(SourceItem::StaticFragment {
826        path: fragment_name,
827    });
828    static_group.clear();
829    Ok(())
830}
831
832fn sorted_player_ffi_symbols(input: &str) -> Vec<String> {
833    let mut symbols = HashSet::new();
834    let mut cursor = 0;
835    while let Some(offset) = input[cursor..].find("player_ffi_") {
836        let start = cursor + offset;
837        let end = input[start..]
838            .find(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_'))
839            .map_or(input.len(), |relative| start + relative);
840        if input[end..].starts_with('(') {
841            symbols.insert(input[start..end].to_string());
842        }
843        cursor = end;
844    }
845    let mut symbols = symbols.into_iter().collect::<Vec<_>>();
846    symbols.sort();
847    symbols
848}
849
850fn ownership_notes(input: &str) -> Vec<String> {
851    let mut notes = Vec::new();
852    if input.contains("player_ffi_error_free(") {
853        notes.push("Frees Rust-owned error messages with player_ffi_error_free.".to_string());
854    }
855    if input.contains("player_ffi_preload_command_list_free(") {
856        notes.push(
857            "Frees Rust-owned preload command lists after copying them to runtime DTOs."
858                .to_string(),
859        );
860    }
861    if input.contains("player_ffi_playlist_active_item_free(") {
862        notes.push(
863            "Frees Rust-owned playlist active item strings after copying them to runtime DTOs."
864                .to_string(),
865        );
866    }
867    if input.contains("player_ffi_download_snapshot_free(") {
868        notes.push(
869            "Frees Rust-owned download snapshots after copying them to runtime DTOs.".to_string(),
870        );
871    }
872    if input.contains("player_ffi_download_command_list_free(") {
873        notes.push(
874            "Frees Rust-owned download command lists after copying them to runtime DTOs."
875                .to_string(),
876        );
877    }
878    if input.contains("player_ffi_download_event_list_free(") {
879        notes.push(
880            "Frees Rust-owned download event lists after copying them to runtime DTOs.".to_string(),
881        );
882    }
883    if input.contains("player_ffi_track_preferences_free(") {
884        notes.push(
885            "Frees Rust-owned resolved track preference strings after copying them to runtime DTOs."
886                .to_string(),
887        );
888    }
889    if input.contains("player_ffi_benchmark_report_string_free(") {
890        notes.push(
891            "Forwards benchmark report string release to the Rust FFI free function.".to_string(),
892        );
893    }
894    if input.contains("player_ffi_dash_bridge_string_free(") {
895        notes
896            .push("Forwards DASH bridge string release to the Rust FFI free function.".to_string());
897    }
898    if input.contains("free(") {
899        notes.push("Releases C-owned bridge allocations with free.".to_string());
900    }
901    if input.contains("calloc(") {
902        notes.push(
903            "Allocates copied C bridge DTO arrays with calloc and pairs them with bridge free wrappers."
904                .to_string(),
905        );
906    }
907    notes
908}
909
910fn next_non_whitespace(input: &str, cursor: usize) -> Option<usize> {
911    input[cursor..]
912        .char_indices()
913        .find(|(_, ch)| !ch.is_whitespace())
914        .map(|(offset, _)| cursor + offset)
915}
916
917fn find_top_level_open_brace(input: &str, start: usize) -> Result<usize> {
918    input[start..]
919        .find('{')
920        .map(|offset| start + offset)
921        .with_context(|| format!("could not find function body after byte {start}"))
922}
923
924fn top_level_function_name(signature: &str) -> Result<String> {
925    let open = signature
926        .rfind('(')
927        .with_context(|| format!("function signature missing (: {signature}"))?;
928    let prefix = signature[..open].trim_end();
929    let name_end = prefix.len();
930    let name_start = prefix[..name_end]
931        .rfind(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_'))
932        .map_or(0, |index| index + 1);
933    let name = &prefix[name_start..name_end];
934    if name.is_empty() {
935        bail!("function signature missing name: {signature}");
936    }
937    Ok(name.to_string())
938}
939
940fn find_matching_brace(input: &str, open_brace: usize) -> Result<usize> {
941    let bytes = input.as_bytes();
942    let mut index = open_brace;
943    let mut depth = 0usize;
944    let mut in_line_comment = false;
945    let mut in_block_comment = false;
946    let mut in_string = false;
947    let mut in_char = false;
948    let mut escaped = false;
949
950    while index < bytes.len() {
951        let byte = bytes[index];
952        let next = bytes.get(index + 1).copied();
953
954        if in_line_comment {
955            if byte == b'\n' {
956                in_line_comment = false;
957            }
958            index += 1;
959            continue;
960        }
961        if in_block_comment {
962            if byte == b'*' && next == Some(b'/') {
963                in_block_comment = false;
964                index += 2;
965            } else {
966                index += 1;
967            }
968            continue;
969        }
970        if in_string {
971            if escaped {
972                escaped = false;
973            } else if byte == b'\\' {
974                escaped = true;
975            } else if byte == b'"' {
976                in_string = false;
977            }
978            index += 1;
979            continue;
980        }
981        if in_char {
982            if escaped {
983                escaped = false;
984            } else if byte == b'\\' {
985                escaped = true;
986            } else if byte == b'\'' {
987                in_char = false;
988            }
989            index += 1;
990            continue;
991        }
992
993        if byte == b'/' && next == Some(b'/') {
994            in_line_comment = true;
995            index += 2;
996            continue;
997        }
998        if byte == b'/' && next == Some(b'*') {
999            in_block_comment = true;
1000            index += 2;
1001            continue;
1002        }
1003        if byte == b'"' {
1004            in_string = true;
1005            index += 1;
1006            continue;
1007        }
1008        if byte == b'\'' {
1009            in_char = true;
1010            index += 1;
1011            continue;
1012        }
1013        if byte == b'{' {
1014            depth += 1;
1015        } else if byte == b'}' {
1016            depth = depth
1017                .checked_sub(1)
1018                .with_context(|| format!("unbalanced closing brace at byte {index}"))?;
1019            if depth == 0 {
1020                return Ok(index);
1021            }
1022        }
1023        index += 1;
1024    }
1025
1026    bail!("could not find matching brace after byte {open_brace}")
1027}
1028
1029fn parse_header_declarations(header: &str) -> Result<Vec<Declaration>> {
1030    let mut body = header;
1031    if let Some(index) = body.find("#define VESPER_PLAYER_KIT_BRIDGE_SHIM_H") {
1032        body = &body[index + "#define VESPER_PLAYER_KIT_BRIDGE_SHIM_H".len()..];
1033    }
1034    let mut declarations = Vec::new();
1035    let mut cursor = 0;
1036    while let Some(start) = find_next_declaration_start(body, cursor) {
1037        if body[start..].starts_with("typedef enum ") {
1038            let (declaration, end) = parse_enum_declaration(body, start)?;
1039            declarations.push(declaration);
1040            cursor = end;
1041        } else if body[start..].starts_with("typedef struct ") {
1042            let (declaration, end) = parse_struct_declaration(body, start)?;
1043            declarations.push(declaration);
1044            cursor = end;
1045        } else {
1046            let (declaration, end) = parse_function_prototype(body, start)?;
1047            declarations.push(declaration);
1048            cursor = end;
1049        }
1050    }
1051    Ok(declarations)
1052}
1053
1054/// Returns the public function names declared by a bridge shim header.
1055pub fn public_function_names(header: &str) -> Result<BTreeSet<String>> {
1056    Ok(parse_header_declarations(header)?
1057        .into_iter()
1058        .filter_map(|declaration| match declaration {
1059            Declaration::Function { name, .. } => Some(name),
1060            Declaration::Enum { .. } | Declaration::Struct { .. } => None,
1061        })
1062        .collect())
1063}
1064
1065fn parse_source_sections(source: &str) -> Result<(Vec<String>, Vec<Declaration>, String)> {
1066    let mut c_includes = Vec::new();
1067    let mut cursor = 0;
1068    let generated_notice_line = format!("/* {GENERATED_NOTICE} */");
1069    if source.starts_with(&generated_notice_line) {
1070        cursor = generated_notice_line.len();
1071        if source[cursor..].starts_with('\n') {
1072            cursor += 1;
1073        }
1074    }
1075    for line in source[cursor..].lines() {
1076        if line.starts_with("#include ") {
1077            c_includes.push(line.trim_start_matches("#include ").to_string());
1078            cursor += line.len() + 1;
1079        } else if line.trim().is_empty() {
1080            cursor += line.len() + 1;
1081        } else {
1082            break;
1083        }
1084    }
1085
1086    let mut declarations = Vec::new();
1087    let mut body_start = cursor;
1088    loop {
1089        let rest = &source[body_start..];
1090        let trimmed = rest.trim_start();
1091        body_start += rest.len() - trimmed.len();
1092        if trimmed.starts_with("typedef enum ") {
1093            let (declaration, end) = parse_enum_declaration(source, body_start)?;
1094            declarations.push(declaration);
1095            body_start = end;
1096        } else if trimmed.starts_with("typedef struct ") {
1097            let (declaration, end) = parse_struct_declaration(source, body_start)?;
1098            declarations.push(declaration);
1099            body_start = end;
1100        } else if trimmed.starts_with("extern ") {
1101            let (declaration, end) = parse_extern_prototype(source, body_start)?;
1102            declarations.push(declaration);
1103            body_start = end;
1104        } else {
1105            break;
1106        }
1107    }
1108
1109    Ok((c_includes, declarations, source[body_start..].to_string()))
1110}
1111
1112fn find_next_declaration_start(input: &str, cursor: usize) -> Option<usize> {
1113    let candidates = [
1114        input[cursor..]
1115            .find("typedef enum ")
1116            .map(|offset| cursor + offset),
1117        input[cursor..]
1118            .find("typedef struct ")
1119            .map(|offset| cursor + offset),
1120        input[cursor..]
1121            .find("\nbool ")
1122            .map(|offset| cursor + offset + 1),
1123        input[cursor..]
1124            .find("\nvoid ")
1125            .map(|offset| cursor + offset + 1),
1126        input[cursor..]
1127            .find("\nuint64_t ")
1128            .map(|offset| cursor + offset + 1),
1129        input[cursor..]
1130            .find("\nchar *")
1131            .map(|offset| cursor + offset + 1),
1132    ];
1133    candidates.into_iter().flatten().min()
1134}
1135
1136fn parse_enum_declaration(input: &str, start: usize) -> Result<(Declaration, usize)> {
1137    let end = find_typedef_declaration_end(input, start)?;
1138    let block = &input[start..end];
1139    let name = text_between(block, "typedef enum ", " {")?
1140        .trim()
1141        .to_string();
1142    let body = text_between(block, "{", "}")?;
1143    let mut variants = Vec::new();
1144    for line in body.lines() {
1145        let line = line.trim().trim_end_matches(',');
1146        if line.is_empty() {
1147            continue;
1148        }
1149        let (name, value) = line
1150            .split_once('=')
1151            .with_context(|| format!("enum variant missing value: {line}"))?;
1152        variants.push(EnumVariant {
1153            name: name.trim().to_string(),
1154            value: value.trim().parse()?,
1155        });
1156    }
1157    Ok((Declaration::Enum { name, variants }, end))
1158}
1159
1160fn parse_struct_declaration(input: &str, start: usize) -> Result<(Declaration, usize)> {
1161    let end = find_typedef_declaration_end(input, start)?;
1162    let block = &input[start..end];
1163    let name = text_between(block, "typedef struct ", " {")?
1164        .trim()
1165        .to_string();
1166    let body = text_between(block, "{", "}")?;
1167    let mut fields = Vec::new();
1168    for line in body.lines() {
1169        let line = line.trim().trim_end_matches(';');
1170        if line.is_empty() {
1171            continue;
1172        }
1173        let field = parse_named_type(line)?;
1174        fields.push(Field {
1175            ty: field.ty,
1176            name: field.name,
1177        });
1178    }
1179    Ok((Declaration::Struct { name, fields }, end))
1180}
1181
1182fn parse_extern_prototype(input: &str, start: usize) -> Result<(Declaration, usize)> {
1183    let end = find_declaration_end(input, start)?;
1184    let block = input[start..end].trim();
1185    let prototype = block
1186        .strip_prefix("extern ")
1187        .context("extern declaration missing extern prefix")?;
1188    parse_function_declaration_text(prototype, FunctionStorage::Extern)
1189        .map(|declaration| (declaration, end))
1190}
1191
1192fn parse_function_prototype(input: &str, start: usize) -> Result<(Declaration, usize)> {
1193    let end = find_declaration_end(input, start)?;
1194    parse_function_declaration_text(input[start..end].trim(), FunctionStorage::Public)
1195        .map(|declaration| (declaration, end))
1196}
1197
1198fn parse_function_declaration_text(
1199    prototype: &str,
1200    storage: FunctionStorage,
1201) -> Result<Declaration> {
1202    let prototype = prototype.trim_end_matches(';').trim();
1203    let open = prototype
1204        .find('(')
1205        .with_context(|| format!("function declaration missing (: {prototype}"))?;
1206    let close = prototype
1207        .rfind(')')
1208        .with_context(|| format!("function declaration missing ): {prototype}"))?;
1209    let head = prototype[..open].trim();
1210    let (return_type, name) = split_type_and_name(head)?;
1211    let params_text = prototype[open + 1..close].trim();
1212    let parameters = if params_text.is_empty() || params_text == "void" {
1213        Vec::new()
1214    } else {
1215        params_text
1216            .split(',')
1217            .map(|param| parse_named_type(param.trim()))
1218            .collect::<Result<Vec<_>>>()?
1219    };
1220    Ok(Declaration::Function {
1221        return_type,
1222        name,
1223        parameters,
1224        storage,
1225    })
1226}
1227
1228fn parse_named_type(value: &str) -> Result<Parameter> {
1229    let (ty, name) = split_type_and_name(value)?;
1230    Ok(Parameter { ty, name })
1231}
1232
1233fn split_type_and_name(value: &str) -> Result<(String, String)> {
1234    let value = value.trim();
1235    if let Some(marker) = value.find("(*") {
1236        let name_start = marker + 2;
1237        let name_end = value[name_start..]
1238            .find(')')
1239            .map(|offset| name_start + offset)
1240            .with_context(|| format!("function pointer declaration is missing ): {value}"))?;
1241        let prefix = value[..marker].trim_end();
1242        let name = value[name_start..name_end].trim().to_string();
1243        let suffix = &value[name_end + 1..];
1244        if prefix.is_empty() || name.is_empty() || suffix.is_empty() {
1245            bail!("function pointer declaration is missing type or name: {value}");
1246        }
1247        return Ok((format!("{prefix} (*){suffix}"), name));
1248    }
1249
1250    let trimmed = value.trim_end();
1251    let name_end = trimmed.len();
1252    let name_start = trimmed[..name_end]
1253        .rfind(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_'))
1254        .map_or(0, |index| index + 1);
1255    let ty = trimmed[..name_start].trim_end().to_string();
1256    let name = trimmed[name_start..name_end].to_string();
1257    if ty.is_empty() || name.is_empty() {
1258        bail!("declaration is missing type or name: {value}");
1259    }
1260    Ok((ty, name))
1261}
1262
1263fn find_declaration_end(input: &str, start: usize) -> Result<usize> {
1264    input[start..]
1265        .find(';')
1266        .map(|offset| start + offset + 1)
1267        .with_context(|| format!("could not find declaration terminator after byte {start}"))
1268}
1269
1270fn find_typedef_declaration_end(input: &str, start: usize) -> Result<usize> {
1271    let closing_brace = input[start..]
1272        .find("\n}")
1273        .map(|offset| start + offset + 1)
1274        .with_context(|| format!("could not find typedef closing brace after byte {start}"))?;
1275    input[closing_brace..]
1276        .find(';')
1277        .map(|offset| closing_brace + offset + 1)
1278        .with_context(|| format!("could not find typedef terminator after byte {start}"))
1279}
1280
1281fn text_between<'a>(input: &'a str, start: &str, end: &str) -> Result<&'a str> {
1282    let start_index = input
1283        .find(start)
1284        .with_context(|| format!("missing start marker: {start}"))?
1285        + start.len();
1286    let end_index = input[start_index..]
1287        .find(end)
1288        .with_context(|| format!("missing end marker: {end}"))?
1289        + start_index;
1290    Ok(&input[start_index..end_index])
1291}
1292
1293#[cfg(test)]
1294mod tests {
1295    use super::*;
1296    use std::time::{SystemTime, UNIX_EPOCH};
1297
1298    fn static_fragment_manifest(path: &str) -> Manifest {
1299        Manifest {
1300            header_guard: "VESPER_PLAYER_KIT_BRIDGE_SHIM_H".to_string(),
1301            header_includes: vec!["<stdbool.h>".to_string()],
1302            c_includes: vec!["\"include/VesperPlayerKitBridgeShim.h\"".to_string()],
1303            public_declarations: Vec::new(),
1304            private_declarations: Vec::new(),
1305            source_items: vec![SourceItem::StaticFragment {
1306                path: path.to_string(),
1307            }],
1308        }
1309    }
1310
1311    #[test]
1312    fn parses_pointer_type_with_name() {
1313        let parameter = parse_named_type("const char *message").unwrap();
1314        assert_eq!(parameter.ty, "const char *");
1315        assert_eq!(parameter.name, "message");
1316    }
1317
1318    #[test]
1319    fn parses_plain_type_with_name() {
1320        let parameter = parse_named_type("uint64_t handle").unwrap();
1321        assert_eq!(parameter.ty, "uint64_t");
1322        assert_eq!(parameter.name, "handle");
1323    }
1324
1325    #[test]
1326    fn emits_multiline_function_prototype() {
1327        let mut output = String::new();
1328        push_function_declaration(
1329            &mut output,
1330            "bool",
1331            "vesper_runtime_example",
1332            &[
1333                Parameter {
1334                    ty: "uint64_t".to_string(),
1335                    name: "handle".to_string(),
1336                },
1337                Parameter {
1338                    ty: "const char *".to_string(),
1339                    name: "message".to_string(),
1340                },
1341            ],
1342            &FunctionStorage::Public,
1343        )
1344        .unwrap();
1345        assert_eq!(
1346            output,
1347            "bool vesper_runtime_example(\n    uint64_t handle,\n    const char *message);\n"
1348        );
1349    }
1350
1351    #[test]
1352    fn parses_function_pointer_field() {
1353        let parameter =
1354            parse_named_type("void (*on_progress)(void *context, float ratio)").unwrap();
1355        assert_eq!(parameter.ty, "void (*)(void *context, float ratio)");
1356        assert_eq!(parameter.name, "on_progress");
1357
1358        let mut output = String::new();
1359        push_typed_name(&mut output, &parameter.ty, &parameter.name);
1360        assert_eq!(output, "void (*on_progress)(void *context, float ratio)");
1361    }
1362
1363    #[test]
1364    fn rejects_wrapper_manifest_with_missing_ffi_symbol_call() {
1365        let temp_dir = std::env::temp_dir().join(format!(
1366            "vesper-shim-generator-test-{}",
1367            SystemTime::now()
1368                .duration_since(UNIX_EPOCH)
1369                .unwrap()
1370                .as_nanos()
1371        ));
1372        std::fs::create_dir_all(temp_dir.join("fragments/wrappers")).unwrap();
1373        std::fs::write(
1374            temp_dir.join("fragments/wrappers/vesper_runtime_example.c.inc"),
1375            "{\n  return true;\n}\n",
1376        )
1377        .unwrap();
1378
1379        let manifest = Manifest {
1380            header_guard: "VESPER_PLAYER_KIT_BRIDGE_SHIM_H".to_string(),
1381            header_includes: vec!["<stdbool.h>".to_string()],
1382            c_includes: vec!["\"include/VesperPlayerKitBridgeShim.h\"".to_string()],
1383            public_declarations: vec![Declaration::Function {
1384                return_type: "bool".to_string(),
1385                name: "vesper_runtime_example".to_string(),
1386                parameters: Vec::new(),
1387                storage: FunctionStorage::Public,
1388            }],
1389            private_declarations: Vec::new(),
1390            source_items: vec![SourceItem::Wrapper {
1391                function: "vesper_runtime_example".to_string(),
1392                ffi_symbols: vec!["player_ffi_missing".to_string()],
1393                ownership: Vec::new(),
1394                body_fragment: "fragments/wrappers/vesper_runtime_example.c.inc".to_string(),
1395            }],
1396        };
1397
1398        let error = generate_source(&manifest, &temp_dir).unwrap_err();
1399        let _ = std::fs::remove_dir_all(&temp_dir);
1400        assert!(error.to_string().contains("player_ffi_missing"));
1401    }
1402
1403    #[test]
1404    fn rejects_generated_ffi_reference_omitted_from_manifest_symbols() {
1405        let directory = tempfile::tempdir().expect("temporary bridge FFI symbol fixture");
1406        let fragment_directory = directory.path().join("fragments/wrappers");
1407        fs::create_dir_all(&fragment_directory).expect("create bridge wrapper directory");
1408        fs::write(
1409            fragment_directory.join("vesper_runtime_example.c.inc"),
1410            "{\n  return player_ffi_missing();\n}\n",
1411        )
1412        .expect("write bridge wrapper with omitted FFI symbol");
1413        let manifest = Manifest {
1414            header_guard: "VESPER_PLAYER_KIT_BRIDGE_SHIM_H".to_string(),
1415            header_includes: vec!["<stdbool.h>".to_string()],
1416            c_includes: vec!["\"include/VesperPlayerKitBridgeShim.h\"".to_string()],
1417            public_declarations: vec![Declaration::Function {
1418                return_type: "bool".to_string(),
1419                name: "vesper_runtime_example".to_string(),
1420                parameters: Vec::new(),
1421                storage: FunctionStorage::Public,
1422            }],
1423            private_declarations: vec![Declaration::Function {
1424                return_type: "bool".to_string(),
1425                name: "player_ffi_missing".to_string(),
1426                parameters: Vec::new(),
1427                storage: FunctionStorage::Extern,
1428            }],
1429            source_items: vec![SourceItem::Wrapper {
1430                function: "vesper_runtime_example".to_string(),
1431                ffi_symbols: Vec::new(),
1432                ownership: Vec::new(),
1433                body_fragment: "fragments/wrappers/vesper_runtime_example.c.inc".to_string(),
1434            }],
1435        };
1436
1437        let error = generate(&manifest, directory.path())
1438            .expect_err("reject generated FFI references omitted from manifest symbols");
1439
1440        assert!(error.to_string().contains("player_ffi_missing"));
1441        assert!(error.to_string().contains("missing from manifest"));
1442    }
1443
1444    #[test]
1445    fn rejects_parent_fragment_path_escape() {
1446        let directory = tempfile::tempdir().expect("temporary bridge fragment boundary");
1447        let manifest_directory = directory.path().join("manifest");
1448        fs::create_dir(&manifest_directory).expect("create bridge manifest directory");
1449        fs::write(directory.path().join("outside.c.inc"), "int outside = 1;\n")
1450            .expect("write outside bridge fragment");
1451
1452        let error = generate_source(
1453            &static_fragment_manifest("../outside.c.inc"),
1454            &manifest_directory,
1455        )
1456        .expect_err("reject a parent bridge fragment path");
1457
1458        assert!(error.to_string().contains("relative fragment path"));
1459    }
1460
1461    #[cfg(unix)]
1462    #[test]
1463    fn rejects_symlinked_fragment() {
1464        use std::os::unix::fs::symlink;
1465
1466        let directory = tempfile::tempdir().expect("temporary bridge symlink boundary");
1467        let manifest_directory = directory.path().join("manifest");
1468        fs::create_dir(&manifest_directory).expect("create bridge manifest directory");
1469        let outside = directory.path().join("outside.c.inc");
1470        fs::write(&outside, "int outside = 1;\n").expect("write outside bridge fragment");
1471        symlink(&outside, manifest_directory.join("linked.c.inc"))
1472            .expect("link outside bridge fragment");
1473
1474        let error = generate_source(
1475            &static_fragment_manifest("linked.c.inc"),
1476            &manifest_directory,
1477        )
1478        .expect_err("reject a symlinked bridge fragment");
1479
1480        assert!(error.to_string().contains("regular non-symlink file"));
1481    }
1482}