1 of 73

Writing a PHP

extension in Rust

Or

What is a PHP extension?

Levi Morrison

2 of 73

There’s a lot to cover here

PHP extensions aren’t something a lot of people know.

PHP extensions are normally written in C or C++.

This is about PHP extensions in Rust.

This talk is only ~50 minutes. I don’t have time to teach Rust too, but I’ll try to keep it accessible.

I might skip some slides.

​

3 of 73

About Me

Authored over 10 RFCs, some which were accepted include:

  • Return types
  • Nullable Types
  • Covariant return types and contravariant parameter types

Have been working at Datadog for the past 6 years on their PHP extensions.

​

​

4 of 73

I write PHP extensions at Datadog

Two years working on ddtrace.

  • Written mostly in C.
  • Targets 10+ versions of PHP.
  • Few native libraries are designed to work well when embedded into PHP.
  • Few coworkers want to write C.

It’s very hard.

Four years working datadog-profiling.

  • Written mostly in Rust.
  • Targets 9+ versions of PHP.
  • Rust libraries are far more reusable, though some don’t work well embedded in PHP.
  • Coworkers want to write Rust.

Works best when you don’t have to embed too deeply into the engine.

5 of 73

Examples of Extensions

  • curl Client URL Library
  • dom Document Object Model
  • ds Data Structures
  • gd Graphics and Image Manipulation
  • intl Internationalization
  • mysqlnd MySQL Native Driver
  • pdo Databases
  • random Random Numbers
  • xdebug Debugger and Profiler Tool
  • zmq ZeroMQ Message Queues

6 of 73

PHP extensions are libraries

So let’s make a Rust library:

% cargo init --lib native

% tree -C "native"

native

├── Cargo.toml

└── src

└── lib.rs

% cd native

7 of 73

% cat -n "Cargo.toml"

1 [package]

2 name = "native"

3 version = "0.1.0"

4 edition = "2021"

5

6 [lib]

7 crate-type = ["cdylib"]

8

9 [dependencies]

10 libc = "0.2"

8 of 73

Build it

% cargo build� Updating crates.io index� Locking 2 packages to latest compatible versions� Compiling libc v0.2.159� Compiling native v0.1.0� Finished `dev` profile [unoptimized + debuginfo] target(s) in 20.63s

​

% file "target/debug/libnative.dylib"�target/debug/libnative.dylib: Mach-O 64-bit dynamically linked shared library arm64

9 of 73

Run it

% php -d "extension=target/debug/libnative.dylib" --version

Warning: PHP Startup: Invalid library (maybe not a PHP library) 'target/debug/libnative.dylib' in Unknown on line 0

​

10 of 73

Search for error on heap.space

11 of 73

115 zend_module_entry *(*get_module)(void);

...

187 get_module = (zend_module_entry *(*)(void))

DL_FETCH_SYMBOL(handle, "get_module");

...

197 if (!get_module) {

...

203 DL_UNLOAD(handle);

204 php_error_docref(NULL, error_type, "Invalid library (maybe not a PHP library) '%s'", filename);

205 return FAILURE;

206 }

12 of 73

1 mod zend {� 2 #[repr(C)]� 3 pub struct ModuleEntry {}� 4 }� 5 � 7 mod module {� 8 use crate::zend;� 9 � 10 #[no_mangle]� 11 extern "C" fn get_module() -> *mut zend::ModuleEntry {� 12 core::ptr::null_mut()� 13 }� 14 }

​

​

lib.rs

13 of 73

% cargo build

% php -d "extension=target/debug/libnative.dylib" --version

zsh: segmentation fault php -dextension=target/debug/...

14 of 73

* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x20)

frame #0: 0x0000000100229164 php`php_load_extension(filename=<unavailable>, type=1, start_now=0) at dl.c:208:59 [opt]

205 return FAILURE;

206 }

207 module_entry = get_module();

-> 208 if (zend_hash_str_exists(&module_registry, module_entry->name, strlen(module_entry->name))) {

209 zend_error(E_CORE_WARNING, "Module \"%s\" is already loaded", module_entry->name);

210 DL_UNLOAD(handle);

211 return FAILURE;

15 of 73

zend_module_entry native_module_entry = {

STANDARD_MODULE_HEADER,

"native", /* Extension name */

ext_functions, /* zend_function_entry */

NULL, /* PHP_MINIT - Module init */

NULL, /* PHP_MSHUTDOWN - Module shutdown */

PHP_RINIT(native), /* PHP_RINIT - Request init */

NULL, /* PHP_RSHUTDOWN - Request shutdown */

PHP_MINFO(native), /* PHP_MINFO - Module info */

PHP_NATIVE_VERSION, /* Version */

STANDARD_MODULE_PROPERTIES

};

16 of 73

71 struct _zend_module_entry {

...

78 const char *name;

79 const struct _zend_function_entry *functions;

80 zend_result (*module_startup_func)(INIT_FUNC_ARGS);

81 zend_result (*module_shutdown_func)(SHUTDOWN_FUNC_ARGS);

82 zend_result (*request_startup_func)(INIT_FUNC_ARGS);

83 zend_result (*request_shutdown_func)(SHUTDOWN_FUNC_ARGS);

84 void (*info_func)(ZEND_MODULE_INFO_FUNC_ARGS);

85 const char *version;

...

87 #ifdef ZTS

88 ts_rsrc_id* globals_id_ptr;

89 #else

90 void* globals_ptr;

91 #endif

...

17 of 73

Split into different files

% tree -C "src"

src

├── lib.rs

├── module.rs

└── zend.rs

​

18 of 73

zend.rs

3 #[repr(C)]

4 pub enum Result {

5 Success = 0,

6 Failure = -1,

7 }

...

18 #[repr(C)]

19 pub struct ModuleEntry {

20 pub size: libc::c_ushort,

21 pub zend_api: libc::c_uint,

22 pub zend_debug: libc::c_uchar,

23 pub zts: libc::c_uchar

...

36 #[cfg(php_zts)]

37 pub globals_id_ptr: *mut ts_rsrc_id,

38 #[cfg(not(php_zts))]

39 pub globals_ptr: *mut libc::c_void,

...

49 }

19 of 73

zend.rs

51 impl Default for ModuleEntry {

52 fn default() -> ModuleEntry {

53 ModuleEntry {

54 size: 0,

55 zend_api: 0,

56 zend_debug: 0,

57 zts: 0,

58 ini_entry: null(),

...

78 }

79 }

80 }

20 of 73

% cat -n "src/lib.rs"� 1 mod module;� 2 mod zend;

% cat -n "src/module.rs"� 1 use crate::zend;� 2 � 3 #[no_mangle]� 4 extern "C" fn get_module() -> *mut zend::ModuleEntry {� 5 Box::leak(Box::new(zend::ModuleEntry::default()))� 6 }

​

21 of 73

* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x20)

frame #1: 0x0000000100229170 php`php_load_extension(filename=<unavailable>, type=1, start_now=0) at dl.c:208:65 [opt]

205 return FAILURE;

206 }

207 module_entry = get_module();

-> 208 if (zend_hash_str_exists(&module_registry, module_entry->name, strlen(module_entry->name))) {

209 zend_error(E_CORE_WARNING, "Module \"%s\" is already loaded", module_entry->name);

210 DL_UNLOAD(handle);

211 return FAILURE;

22 of 73

module.rs

1 use crate::zend;

2

3 #[no_mangle]

4 extern "C" fn get_module() -> *mut zend::ModuleEntry {

5 Box::leak(Box::new(zend::ModuleEntry {

-> 6 name: c"native".as_ptr(),

7 ..Default::default()

8 }))

9 }

23 of 73

% cargo build

Compiling native v0.1.0 (%s/writing-a-php-extension-in-rust)�error: null characters in C string literals are not supported

--> src/module.rs:6:23

|

6 | name: c"native\0".as_ptr(),

| ^^

24 of 73

% cargo build

% php -d "extension=target/debug/libnative.dylib" --version

Warning: PHP Startup: native: Unable to initialize module

Module compiled with module API=0

PHP compiled with module API=20230831

These options need to match

in Unknown on line 0

�PHP 8.3.8-dev (cli) (built: May 17 2024 11:49:59) (NTS)

Copyright (c) The PHP Group

Zend Engine v4.3.8-dev, Copyright (c) Zend Technologies

25 of 73

module.rs

1 use crate::zend;

2

3 #[no_mangle]

4 extern "C" fn get_module() -> *mut zend::ModuleEntry {

5 Box::leak(Box::new(zend::ModuleEntry {

-> 6 zend_api: 20230831,

7 name: c"native".as_ptr(),

8 ..Default::default()

9 }))

10 }

26 of 73

% cargo build

% php -d "extension=target/debug/libnative.dylib" --version

zsh: segmentation fault php -dextension=target/debug/...

27 of 73

* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x20)

frame #1: 0x00000001002291d0 php`php_load_extension(filename=<unavailable>, type=1, start_now=0) at dl.c:223:5 [opt]

220 DL_UNLOAD(handle);

222 return FAILURE;

222 }

-> 223 if (strcmp(module_entry->build_id, ZEND_MODULE_BUILD_ID)) {

224 php_error_docref(NULL, error_type,

225 "%s: Unable to initialize module\n"

226 "Module compiled with build ID=%s\n"

28 of 73

module.rs

1 use crate::zend;

2

3 #[no_mangle]

4 extern "C" fn get_module() -> *mut zend::ModuleEntry {

5 Box::leak(Box::new(zend::ModuleEntry {

6 zend_api: 20230831,

7 name: c"native".as_ptr(),

-> 8 build_id: c"".as_ptr(),

9 ..Default::default()

10 }))

11 }

​

29 of 73

% cargo build

% php -d "extension=target/debug/libnative.dylib" --version

Warning: PHP Startup: native: Unable to initialize module

Module compiled with build ID=

PHP compiled with build ID=API20230831,NTS

These options need to match

30 of 73

module.rs

1 use crate::zend;

2

3 #[no_mangle]

4 extern "C" fn get_module() -> *mut zend::ModuleEntry {

5 Box::leak(Box::new(zend::ModuleEntry {

6 zend_api: 20230831,

7 name: c"native".as_ptr(),

-> 8 build_id: c"API20230831,NTS".as_ptr(),

9 ..Default::default()

10 }))

11 }

​

31 of 73

% cargo build

% php -d "extension=target/debug/libnative.dylib" --version

PHP 8.3.8-dev (cli) (built: May 17 2024 11:49:59) (NTS)

Copyright (c) The PHP Group

Zend Engine v4.3.8-dev, Copyright (c) Zend Technologies

32 of 73

What is in a zend_module_entry?

  • A bunch of configuration options.
  • Embedded PHP version information.
  • A list of PHP functions to register.
  • Many, many optional hooks into the engine.
  • “Global” storage for PHP’s request globals.
  • Informational fields like version, author, etc.
  • Record-keeping fields for the engine.

33 of 73

What is in a zend_module_entry?

  • A bunch of configuration options.
  • Embedded PHP version information.
  • A list of PHP functions to register.
  • Many, many optional hooks into the engine.
  • “Global” storage for PHP’s request globals.
  • Informational fields like version, author, etc.
  • Record-keeping fields for the engine.

34 of 73

PHP ext_skel script

% cd "$php_src/ext"

% php ext_skel.php --ext native --onlyunix

% tree -C native

native

├── config.m4

├── native.c

├── native.stub.php

├── native_arginfo.h

├── php_native.h

└── tests

├── 001.phpt

├── 002.phpt

└── 003.phpt

35 of 73

phpize + configure + make

% phpize # These numbers are for PHP 8.3�Configuring for:�PHP Api Version: 20230831�Zend Module Api No: 20230831�Zend Extension Api No: 420230831

% ./configure # Standard

% make # Standard

% make install # Standard

36 of 73

How do we “oxidize” this process?

cargo has tools to handle configure and make, but not make install.

What about phpize?

​

37 of 73

Build Scripts

“Placing a file named build.rs in the root of a package will cause Cargo to compile that script and execute it just before building the package.

Some example use cases of build scripts are:

  • Building a bundled C library.
  • Finding a C library on the host system.
  • Generating a Rust module from a specification.
  • Performing any platform-specific configuration needed for the crate.”

38 of 73

Let’s run PHP scripts in build.rs!

% php -r "echo PHP_ZTS ? 1 : 0, PHP_EOL;"�0

% php -r "echo PHP_DEBUG ? 1 : 0, PHP_EOL;"�0

39 of 73

build.rs

fn php_script_num(arg: &str) -> u64 {

let output = std::process::Command::new("php")

.arg("-r")

.arg(arg)

.output()

.expect("Unable to run `php`. Is it in your PATH?");

if !output.status.success() {

match String::from_utf8(output.stderr) {

Ok(stderr) => panic!("`php` failed: {stderr}"),

Err(err) => panic!("`php` failed, not utf-8: {err}"),

}

}

std::str::from_utf8(output.stdout.as_slice())

.expect("`php script stdout to contain valid utf-8")

.trim()

.parse::<u64>()

.expect("php vernum to be a number and fit in u64")

}

40 of 73

build.rs

1 fn php_script_num(arg: &str) -> u64 {

...

18 }

19

20 fn main() {

21 let zts = php_script_num("echo PHP_ZTS ? 1 : 0, PHP_EOL;");

22 let debug = php_script_num(

"echo PHP_DEBUG ? 1 : 0, PHP_EOL;");

23

24 println!("cargo::rustc-check-cfg=cfg(php_zts)");

25 if zts != 0 {

26 println!("cargo:rustc-cfg=php_zts");

27 }

28

29 println!("cargo::rustc-check-cfg=cfg(php_debug)");

30 if debug != 0 {

31 println!("cargo:rustc-cfg=php_debug");

32 }

33 }

41 of 73

src/module.rs

...

3 #[no_mangle]

4 extern "C" fn get_module() -> *mut zend::ModuleEntry {

5 Box::leak(Box::new(zend::ModuleEntry {

6 zend_api: 20230831,

7 name: c"native".as_ptr(),

8 build_id: c"API20230831,NTS".as_ptr(),

-> 9 zend_debug: cfg!(php_debug) as u8,

-> 10 zts: cfg!(php_zts) as u8,

11 ..Default::default()

12 }))

13 }

42 of 73

Build Scripts: bindgen

�typedef struct Doggo {

int many;

char wow;

} Doggo;

#[repr(C)]

pub struct Doggo {

pub many: std::os::raw::c_int,

pub wow: std::os::raw::c_char,

}

“Automatically generates Rust FFI bindings to C (and some C++) libraries.”

43 of 73

Let’s do something!

module_startup_func -> MINIT

module_shutdown_func -> MSHUTDOWN

request_startup_func -> RINIT

request_shutdown_func -> RSHUTDOWN

​

44 of 73

MINIT

PHP_MINIT_FUNCTION(native);

zend_result ZEND_MODULE_STARTUP_N(native)(INIT_FUNC_ARGS);

zend_result zim_startup_native(int type, int module_number);

typedef enum {� SUCCESS = 0,� FAILURE = -1,�} ZEND_RESULT_CODE;�typedef ZEND_RESULT_CODE zend_result;

45 of 73

src/module.rs

1 use crate::zend;

2 use libc::c_int;

3 use std::process;

4

-> 5 extern "C" fn minit(_: c_int, _: c_int) -> zend::Result {

6 eprintln!("[{}] MINIT", process:id());

7 zend::Result::Success

8 }

...

24 #[no_mangle]

25 extern "C" fn get_module() -> *mut zend::ModuleEntry {

26 Box::leak(Box::new(zend::ModuleEntry {

...

30 zend_debug: cfg!(php_debug) as u8,

31 zts: cfg!(php_zts) as u8,

-> 32 module_startup_func: Some(minit),

...

36 ..Default::default()

37 }))

38 }

46 of 73

% cat -n "sleep.php"� 1 <?php� 2 error_log("sleeping...");� 3 sleep(1);

% php -d "extension=target/debug/libnative.dylib" \� -S "0.0.0.0:8080" sleep.php

47 of 73

[Thu Oct 24 21:50:49 2024] PHP 8.3.14-dev Development Server (http://0.0.0.0:8080) started

[Thu Oct 24 21:50:51 2024] 127.0.0.1:63112 Accepted

[68616] RINIT

[Thu Oct 24 21:50:51 2024] sleeping…

[68616] RSHUTDOWN

[Thu Oct 24 21:50:52 2024] 127.0.0.1:63112 Closing

[Thu Oct 24 21:50:52 2024] 127.0.0.1:63114 Accepted

[Thu Oct 24 21:50:52 2024] 127.0.0.1:63115 Accepted

[68616] RINIT

[Thu Oct 24 21:50:52 2024] sleeping…

[68616] RSHUTDOWN

[Thu Oct 24 21:50:53 2024] 127.0.0.1:63114 Closing

[Thu Oct 24 21:50:53 2024] 127.0.0.1:63115 Closed without sending a request; it was probably just an unused speculative preconnection

[Thu Oct 24 21:50:53 2024] 127.0.0.1:63115 Closing

^C[68616] MSHUTDOWN

48 of 73

Module Globals

ZEND_BEGIN_MODULE_GLOBALS(native)

bool at_least_one_request;

ZEND_END_MODULE_GLOBALS(native)

typedef struct _zend_native_globals

{

bool at_least_one_request;

} zend_native_globals;

49 of 73

Module Globals

ZEND_DECLARE_MODULE_GLOBALS(native)

// NTS�zend_native_globals native_globals;

// ZTS�ts_rsrc_id native_globals_id;

50 of 73

Module Globals

struct _zend_module_entry {

//...

#ifdef ZTS

ts_rsrc_id* globals_id_ptr;

#else

void* globals_ptr;

#endif

//...

};

// NTS�zend_native_globals native_globals;

// ZTS�ts_rsrc_id native_globals_id;

51 of 73

C globals are tricky to get right

Globals can be accessed from any code from any thread.

Globals are mutable.

52 of 73

PHP globals are slightly easier

Globals can be accessed from any code from any thread, but generally are localized to the PHP thread.

Globals are still mutable, mostly have to worry about ourselves modifying it though.

53 of 73

Rust doesn’t like shared, mutable state

“Given an object T, it is only possible to have one of the following:

  • Having several immutable references (&T) to the object (also known as aliasing).
  • Having one mutable reference (&mut T) to the object (also known as mutability).

This is enforced by the Rust compiler. However, there are situations where this rule is not flexible enough.”

54 of 73

Plan for module globals in Rust

  1. Define global types.
  2. Define instance of globals.
  3. Define helpers to make it safe.
  4. Finally use globals!

55 of 73

  1. Define Global Types

src/globals.rs

1 use std::cell::RefCell;

2

3 pub struct Globals {

4 pub at_least_one_request: bool,

5 }

7 impl Globals {

8 pub const fn new() -> Globals {

9 Globals {

10 at_least_one_request: false,

11 }

12 }

13 }

15 // A mutable memory location with dynamically checked borrow rules

16 pub type RequestContext = RefCell<Globals>;

56 of 73

2. Define instance of globals

src/module.rs

5 #[cfg(not(php_zts))]

6 #[no_mangle]

7 static mut zend_native_globals: RequestContext =

8 RequestContext::new(Globals::new());

​

Equivalent to NTS version of ZEND_DECLARE_MODULE_GLOBALS(native)

57 of 73

3. Define helpers to make it safe

src/module.rs

#[cfg(not(php_zts))]

fn get_request_context<'a>(

_: &'a PhpThreadContext

) -> &'a RequestContext {

// SAFETY: safe when called from a PHP thread (no

// other access is allowed).

unsafe { &*addr_of!(zend_native_globals) }

}

}

58 of 73

3. Define helpers to make it safe

src/module.rs

// Not pub, no way to construct out of this module!

struct PhpThreadContext {

// Teach Rust not to send nor share across threads.

_marker: PhantomData<*mut ()>,

}

impl PhpThreadContext {

/// # Safety

/// Caller needs to ensure that it is executed on a PHP thread.

unsafe fn new() -> PhpThreadContext {

PhpThreadContext { _marker: PhantomData }

}

}

​

59 of 73

3. Define helpers to make it safe

src/module.rs

fn set_request_served(

php_thread: &PhpThreadContext,

) -> Result<(), BorrowMutError> {

let context = get_request_context(php_thread);

context.try_borrow_mut()?.at_least_one_request = true;

Ok(())

}

60 of 73

4. Finally use globals!

src/module.rs

extern "C" fn rinit(_: c_int, _: c_int) -> zend::Result {

eprintln!("[{}] RINIT", process::id());

​

// SAFETY: rinit is only called by a PHP thread.

let php_thread = unsafe { PhpThreadContext::new() };

if set_request_served(&php_thread).is_err() {

eprintln!(

"[{}] Failed to borrow request context in rinit",

process::id()

);

return zend::Result::Failure;

}

zend::Result::Success

}

​

61 of 73

4. Finally use globals!

src/module.rs

extern "C" fn mshutdown(_: c_int, _: c_int) -> zend::Result {

eprintln!("[{}] MSHUTDOWN", process::id());

​

// SAFETY: mshutdown is only called by a PHP thread.

let php_thread = unsafe { PhpThreadContext::new() };

let globals = get_request_context(&php_thread).borrow();

if globals.at_least_one_request {

eprintln!("[{}] Served at least one request.", process::id());

} else {

eprintln!("[{}] Served no requests.", process::id());

}

zend::Result::Success

}

​

​

62 of 73

PHP Globals in Rust

% export PHP_CLI_SERVER_WORKERS=2

% php -d "extension=target/debug/libnative.dylib" \

-S "0.0.0.0:8080" sleep.php

[13508] MINIT

...

^C[13510] MSHUTDOWN

[13510] Served at least one request.

[13509] MSHUTDOWN

[13509] Served at least one request.

[13508] MSHUTDOWN

[13508] Served no requests.

63 of 73

PHP Globals in Rust

I had to skip some things, see the repository for more information:

https://github.com/morrisonlevi/writing-a-php-extension-in-rust

64 of 73

We did it!

65 of 73

Does it protect anything in practice?

50 extern "C" fn rinit(_: c_int, _: c_int) -> zend::Result {

51 eprintln!("[{}] RINIT", process::id());

52

53 // SAFETY: rinit is only called by a PHP thread.

54 let php_thread = unsafe { PhpThreadContext::new() };

-> 55 let globals = get_request_context(&php_thread).borrow();

56 if let Err(_) = set_request_served(&php_thread) {

57 eprintln!(

58 "[{}] Failed to borrow request context in rinit",

59 process::id()

60 );

61 return zend::Result::Failure;

62 }

-> 63 drop(globals);

64 zend::Result::Success

65 }

66 of 73

Does it protect anything in practice?

% php -d "extension=target/debug/libnative.dylib" \

-S "0.0.0.0:8080" sleep.php

[15162] RINIT

[15162] Failed to borrow request context in rinit

[15160] [Sun Oct 20 19:53:33 2024] 127.0.0.1:51304 Accepted

[15160] RINIT

[15160] Failed to borrow request context in rinit

[15161] [Sun Oct 20 19:53:33 2024] 127.0.0.1:51303 Accepted

[15161] RINIT

[15161] Failed to borrow request context in rinit

​

67 of 73

68 of 73

Does it protect anything in practice?

Yes.

69 of 73

Does it protect anything in practice?

  1. Add hooks for internal function calls and also garbage collection hook.
    1. Use globals in both hooks.
  2. An internal function hook fires.
    • Get a reference to globals
    • While that is alive, do a memory allocation.
  3. Memory allocation triggers garbage collection.
    • The garbage collection hook also uses globals!

This is unintended aliasing.

70 of 73

Datadog Profiler uses Rust

There have been very few crashes in the Datadog profiler, far fewer than the tracer which is mostly still written in C.

We consider writing PHP extensions in Rust to still be difficult but worthwhile.

71 of 73

What other things can extensions do?

  • Transform or view the Abstract Syntax Tree (AST):�https://github.com/morrisonlevi/ddastrace/blob/master/DESIGN.md
  • Hook into file/eval compilation.
  • Hook into garbage collection.
  • Replace opcode handlers.
  • Add interrupt handler.
  • Define classes/types and functions/methods.

https://www.phpinternalsbook.com/php7/extensions_design/hooks.html

72 of 73

Use ext_php_rs and cargo-php instead

use ext_php_rs::prelude::*;

/// Gives you a nice greeting!

/// @param string $name Your name.

/// @return string Nice greeting!

#[php_function]

pub fn hello_world(name: &str) -> String {

format!("Hello, {name}!")

}

// Required to register the extension with PHP.

#[php_module]

pub fn module(module: ModuleBuilder) -> ModuleBuilder {

module

}

73 of 73

Questions?