• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

davidcole1340 / ext-php-rs / 16323306954

16 Jul 2025 03:10PM CUT coverage: 22.222% (+0.6%) from 21.654%
16323306954

Pull #482

github

web-flow
Merge de76d2402 into 1166e2910
Pull Request #482: feat(cargo-php)!: escalate privilege and to copy extension and edit ini file

0 of 48 new or added lines in 1 file covered. (0.0%)

193 existing lines in 10 files now uncovered.

870 of 3915 relevant lines covered (22.22%)

3.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/crates/cli/src/lib.rs
1
#![doc = include_str!("../README.md")]
2

3
#[cfg(not(windows))]
4
mod ext;
5

6
use anyhow::{bail, Context, Result as AResult};
7
use cargo_metadata::{camino::Utf8PathBuf, CrateType, Target};
8
use clap::Parser;
9
use dialoguer::{Confirm, Select};
10

11
use std::{
12
    io::BufReader,
13
    path::PathBuf,
14
    process::{Command, Stdio},
15
};
16

17
/// Generates mock symbols required to generate stub files from a downstream
18
/// crates CLI application.
19
#[macro_export]
20
macro_rules! stub_symbols {
21
    ($($s: ident),*) => {
22
        $(
23
            $crate::stub_symbols!(@INTERNAL; $s);
24
        )*
25
    };
26
    (@INTERNAL; $s: ident) => {
27
        #[allow(non_upper_case_globals)]
28
        #[allow(missing_docs)]
29
        #[no_mangle]
30
        pub static mut $s: *mut () = ::std::ptr::null_mut();
31
    };
32
}
33

34
/// Result type returned from the [`run`] function.
35
pub type CrateResult = AResult<()>;
36

37
/// Runs the CLI application. Returns nothing in a result on success.
38
///
39
/// # Errors
40
///
41
/// Returns an error if the application fails to run.
42
pub fn run() -> CrateResult {
×
43
    let mut args: Vec<_> = std::env::args().collect();
×
44

45
    // When called as a cargo subcommand, the second argument given will be the
46
    // subcommand, in this case `php`. We don't want this so we remove from args and
47
    // pass it to clap.
48
    if args.get(1).is_some_and(|nth| nth == "php") {
×
49
        args.remove(1);
×
50
    }
51

52
    Args::parse_from(args).handle()
×
53
}
54

55
#[derive(Parser)]
56
#[clap(
57
    about = "Installs extensions and generates stub files for PHP extensions generated with `ext-php-rs`.",
58
    author = "David Cole <david.cole1340@gmail.com>",
59
    version = env!("CARGO_PKG_VERSION")
60
)]
61
enum Args {
62
    /// Installs the extension in the current PHP installation.
63
    ///
64
    /// This copies the extension to the PHP installation and adds the
65
    /// extension to a PHP configuration file.
66
    ///
67
    /// Note that this uses the `php-config` executable installed alongside PHP
68
    /// to locate your `php.ini` file and extension directory. If you want to
69
    /// use a different `php-config`, the application will read the `PHP_CONFIG`
70
    /// variable (if it is set), and will use this as the path to the executable
71
    /// instead.
72
    Install(Install),
73
    /// Removes the extension in the current PHP installation.
74
    ///
75
    /// This deletes the extension from the PHP installation and also removes it
76
    /// from the main PHP configuration file.
77
    ///
78
    /// Note that this uses the `php-config` executable installed alongside PHP
79
    /// to locate your `php.ini` file and extension directory. If you want to
80
    /// use a different `php-config`, the application will read the `PHP_CONFIG`
81
    /// variable (if it is set), and will use this as the path to the executable
82
    /// instead.
83
    Remove(Remove),
84
    /// Generates stub PHP files for the extension.
85
    ///
86
    /// These stub files can be used in IDEs to provide typehinting for
87
    /// extension classes, functions and constants.
88
    #[cfg(not(windows))]
89
    Stubs(Stubs),
90
}
91

92
#[allow(clippy::struct_excessive_bools)]
93
#[derive(Parser)]
94
struct Install {
95
    /// Changes the path that the extension is copied to. This will not
96
    /// activate the extension unless `ini_path` is also passed.
97
    #[arg(long)]
98
    #[allow(clippy::struct_field_names)]
99
    install_dir: Option<PathBuf>,
100
    /// Path to the `php.ini` file to update with the new extension.
101
    #[arg(long)]
102
    ini_path: Option<PathBuf>,
103
    /// Installs the extension but doesn't enable the extension in the `php.ini`
104
    /// file.
105
    #[arg(long)]
106
    disable: bool,
107
    /// Whether to install the release version of the extension.
108
    #[arg(long)]
109
    release: bool,
110
    /// Path to the Cargo manifest of the extension. Defaults to the manifest in
111
    /// the directory the command is called.
112
    #[arg(long)]
113
    manifest: Option<PathBuf>,
114
    #[arg(short = 'F', long, num_args = 1..)]
115
    features: Option<Vec<String>>,
116
    #[arg(long)]
117
    all_features: bool,
118
    #[arg(long)]
119
    no_default_features: bool,
120
    /// Whether to bypass the install prompt.
121
    #[clap(long)]
122
    yes: bool,
123
    /// Whether to bypass the root check
124
    #[clap(long)]
125
    bypass_root_check: bool,
126
}
127

128
#[derive(Parser)]
129
struct Remove {
130
    /// Changes the path that the extension will be removed from. This will not
131
    /// remove the extension from a configuration file unless `ini_path` is also
132
    /// passed.
133
    #[arg(long)]
134
    install_dir: Option<PathBuf>,
135
    /// Path to the `php.ini` file to remove the extension from.
136
    #[arg(long)]
137
    ini_path: Option<PathBuf>,
138
    /// Path to the Cargo manifest of the extension. Defaults to the manifest in
139
    /// the directory the command is called.
140
    #[arg(long)]
141
    manifest: Option<PathBuf>,
142
    /// Whether to bypass the remove prompt.
143
    #[clap(long)]
144
    yes: bool,
145
    #[cfg(unix)]
146
    /// Whether to bypass the root check
147
    #[clap(long)]
148
    bypass_root_check: bool,
149
}
150

151
#[cfg(not(windows))]
152
#[derive(Parser)]
153
struct Stubs {
154
    /// Path to extension to generate stubs for. Defaults for searching the
155
    /// directory the executable is located in.
156
    ext: Option<PathBuf>,
157
    /// Path used to store generated stub file. Defaults to writing to
158
    /// `<ext-name>.stubs.php` in the current directory.
159
    #[arg(short, long)]
160
    out: Option<PathBuf>,
161
    /// Print stubs to stdout rather than write to file. Cannot be used with
162
    /// `out`.
163
    #[arg(long, conflicts_with = "out")]
164
    stdout: bool,
165
    /// Path to the Cargo manifest of the extension. Defaults to the manifest in
166
    /// the directory the command is called.
167
    ///
168
    /// This cannot be provided alongside the `ext` option, as that option
169
    /// provides a direct path to the extension shared library.
170
    #[arg(long, conflicts_with = "ext")]
171
    manifest: Option<PathBuf>,
172
    #[arg(short = 'F', long, num_args = 1..)]
173
    features: Option<Vec<String>>,
174
    #[arg(long)]
175
    all_features: bool,
176
    #[arg(long)]
177
    no_default_features: bool,
178
}
179

180
impl Args {
181
    pub fn handle(self) -> CrateResult {
×
182
        match self {
×
183
            Args::Install(install) => install.handle(),
×
184
            Args::Remove(remove) => remove.handle(),
×
185
            #[cfg(not(windows))]
186
            Args::Stubs(stubs) => stubs.handle(),
×
187
        }
188
    }
189
}
190

191
impl Install {
192
    pub fn handle(self) -> CrateResult {
×
193
        let artifact = find_ext(self.manifest.as_ref())?;
×
194
        let ext_path = build_ext(
195
            &artifact,
×
196
            self.release,
×
197
            self.features,
×
198
            self.all_features,
×
199
            self.no_default_features,
×
200
        )?;
201

202
        #[cfg(unix)]
NEW
203
        anyhow::ensure!(
×
NEW
204
            self.bypass_root_check || !is_root(),
×
NEW
205
            "Running as root is not recommended. Use --bypass-root-check to override."
×
206
        );
207

208
        let (mut ext_dir, mut php_ini) = if let Some(install_dir) = self.install_dir {
×
209
            (install_dir, None)
×
210
        } else {
211
            (get_ext_dir()?, Some(get_php_ini()?))
×
212
        };
213

214
        if let Some(ini_path) = self.ini_path {
×
215
            php_ini = Some(ini_path);
×
216
        }
217

218
        if !self.yes
×
219
            && !Confirm::new()
×
220
                .with_prompt(format!(
×
221
                    "Are you sure you want to install the extension `{}`?",
×
222
                    artifact.name
223
                ))
224
                .interact()?
×
225
        {
226
            bail!("Installation cancelled.");
×
227
        }
228

229
        debug_assert!(ext_path.is_file());
×
230
        let ext_name = ext_path.file_name().expect("ext path wasn't a filepath");
×
231

232
        if ext_dir.is_dir() {
×
233
            ext_dir.push(ext_name);
×
234
        }
235

NEW
236
        copy_extension(&ext_path, &ext_dir).with_context(|| {
×
237
            "Failed to copy extension from target directory to extension directory"
×
238
        })?;
239

240
        if let Some(php_ini) = php_ini {
×
NEW
241
            update_ini_file(&php_ini, ext_name, self.disable)
×
UNCOV
242
                .with_context(|| "Failed to update `php.ini`")?;
×
243
        }
244

245
        Ok(())
×
246
    }
247
}
248

249
/// Update extension line in the ini file.
250
///
251
/// Write to a temp file then copy it to a given path. If this fails, then try
252
/// `sudo mv` on unix.
NEW
253
fn update_ini_file(php_ini: &PathBuf, ext_name: &str, disable: bool) -> anyhow::Result<()> {
×
NEW
254
    let current_ini_content = std::fs::read_to_string(php_ini)?;
×
NEW
255
    let mut ext_line = format!("extension={ext_name}");
×
256

NEW
257
    let mut new_lines = current_ini_content.lines().collect::<Vec<_>>();
×
NEW
258
    for line in &new_lines {
×
NEW
259
        if line.contains(&ext_line) {
×
NEW
260
            bail!("Extension already enabled.");
×
261
        }
262
    }
263

264
    // Comment out extension if user specifies disable flag
NEW
265
    if disable {
×
NEW
266
        ext_line.insert(0, ';');
×
267
    }
268

NEW
269
    new_lines.push(&ext_line);
×
270

NEW
271
    write_to_file(new_lines.join("\n"), php_ini)?;
×
NEW
272
    Ok(())
×
273
}
274

275
/// Copy extension, if fails, try with sudo cp.
276
///
277
/// Checking if we have write permission for ext_dir may fail due to ACL, group
278
/// list and and other nuances. See
279
/// https://doc.rust-lang.org/std/fs/struct.Permissions.html#method.readonly.
NEW
280
fn copy_extension(ext_path: &Utf8PathBuf, ext_dir: &PathBuf) -> anyhow::Result<()> {
×
NEW
281
    if let Err(_e) = std::fs::copy(ext_path, ext_dir) {
×
282
        #[cfg(unix)]
283
        {
NEW
284
            let s = std::process::Command::new("sudo")
×
285
                .arg("cp")
NEW
286
                .arg(ext_path)
×
NEW
287
                .arg(ext_dir)
×
288
                .status()?;
NEW
289
            anyhow::ensure!(s.success(), "Failed to copy extension");
×
290
        }
291
        #[cfg(not(unix))]
292
        anyhow::bail!("Failed to copy extension: {_e}");
293
    }
NEW
294
    Ok(())
×
295
}
296

297
/// Returns the path to the extension directory utilised by the PHP interpreter,
298
/// creating it if one was returned but it does not exist.
299
fn get_ext_dir() -> AResult<PathBuf> {
×
300
    let cmd = Command::new("php")
×
301
        .arg("-r")
302
        .arg("echo ini_get('extension_dir');")
303
        .output()
304
        .context("Failed to call PHP")?;
305
    if !cmd.status.success() {
×
306
        bail!("Failed to call PHP: {:?}", cmd);
×
307
    }
308
    let stdout = String::from_utf8_lossy(&cmd.stdout);
×
309
    let ext_dir = PathBuf::from(stdout.rsplit('\n').next().unwrap());
×
310
    if !ext_dir.is_dir() {
×
311
        if ext_dir.exists() {
×
312
            bail!(
×
313
                "Extension directory returned from PHP is not a valid directory: {:?}",
×
314
                ext_dir
315
            );
316
        }
317

318
        std::fs::create_dir(&ext_dir).with_context(|| {
×
319
            format!(
×
320
                "Failed to create extension directory at {}",
×
321
                ext_dir.display()
×
322
            )
323
        })?;
324
    }
325
    Ok(ext_dir)
×
326
}
327

328
/// Returns the path to the `php.ini` loaded by the PHP interpreter.
329
fn get_php_ini() -> AResult<PathBuf> {
×
330
    let cmd = Command::new("php")
×
331
        .arg("-r")
332
        .arg("echo get_cfg_var('cfg_file_path');")
333
        .output()
334
        .context("Failed to call PHP")?;
335
    if !cmd.status.success() {
×
336
        bail!("Failed to call PHP: {:?}", cmd);
×
337
    }
338
    let stdout = String::from_utf8_lossy(&cmd.stdout);
×
339
    let ini = PathBuf::from(stdout.rsplit('\n').next().unwrap());
×
340
    if !ini.is_file() {
×
341
        bail!(
×
342
            "php.ini does not exist or is not a file at the given path: {:?}",
×
343
            ini
344
        );
345
    }
346
    Ok(ini)
×
347
}
348

349
impl Remove {
350
    pub fn handle(self) -> CrateResult {
×
351
        use std::env::consts;
352

353
        let artifact = find_ext(self.manifest.as_ref())?;
×
354

355
        let (mut ext_path, mut php_ini) = if let Some(install_dir) = self.install_dir {
×
356
            (install_dir, None)
×
357
        } else {
358
            (get_ext_dir()?, Some(get_php_ini()?))
×
359
        };
360

361
        if let Some(ini_path) = self.ini_path {
×
362
            php_ini = Some(ini_path);
×
363
        }
364

365
        let ext_file = format!(
×
366
            "{}{}{}",
367
            consts::DLL_PREFIX,
368
            artifact.name.replace('-', "_"),
×
369
            consts::DLL_SUFFIX
370
        );
371
        ext_path.push(&ext_file);
×
372

373
        if !ext_path.is_file() {
×
374
            bail!("Unable to find extension installed.");
×
375
        }
376

377
        if !self.yes
×
378
            && !Confirm::new()
×
379
                .with_prompt(format!(
×
380
                    "Are you sure you want to remove the extension `{}`?",
×
381
                    artifact.name
382
                ))
383
                .interact()?
×
384
        {
385
            bail!("Installation cancelled.");
×
386
        }
387

NEW
388
        if let Err(_e) = std::fs::remove_file(&ext_path) {
×
389
            #[cfg(unix)]
390
            {
NEW
391
                let _ = std::process::Command::new("sudo")
×
392
                    .arg("rm")
393
                    .arg("-f")
NEW
394
                    .arg(&ext_path)
×
395
                    .status()?;
396
            }
397
        }
NEW
398
        anyhow::ensure!(!ext_path.is_file(), "Failed to remove {ext_path:?}");
×
399

400
        // modify the ini file
NEW
401
        if let Some(php_ini) = php_ini.filter(|path| path.is_file()) {
×
NEW
402
            let ini_file_content = std::fs::read_to_string(&php_ini)?;
×
403

NEW
404
            let new_ini_content = ini_file_content
×
405
                .lines()
NEW
406
                .filter(|x| x.contains(&ext_file))
×
407
                .collect::<Vec<_>>()
408
                .join("\n");
NEW
409
            write_to_file(new_ini_content, &php_ini)
×
UNCOV
410
                .with_context(|| "Failed to update `php.ini`")?;
×
411
        }
412

413
        Ok(())
×
414
    }
415
}
416

417
#[cfg(not(windows))]
418
impl Stubs {
419
    pub fn handle(self) -> CrateResult {
×
420
        use ext_php_rs::describe::ToStub;
421
        use std::{borrow::Cow, str::FromStr};
422

423
        let ext_path = if let Some(ext_path) = self.ext {
×
424
            ext_path
×
425
        } else {
426
            let target = find_ext(self.manifest.as_ref())?;
×
427
            build_ext(
428
                &target,
×
429
                false,
430
                self.features,
×
431
                self.all_features,
×
432
                self.no_default_features,
×
433
            )?
434
            .into()
435
        };
436

437
        if !ext_path.is_file() {
×
438
            bail!("Invalid extension path given, not a file.");
×
439
        }
440

441
        let ext = self::ext::Ext::load(ext_path)?;
×
442
        let result = ext.describe();
×
443

444
        // Ensure extension and CLI `ext-php-rs` versions are compatible.
445
        let cli_version = semver::VersionReq::from_str(ext_php_rs::VERSION).with_context(|| {
×
446
            "Failed to parse `ext-php-rs` version that `cargo php` was compiled with"
×
447
        })?;
448
        let ext_version = semver::Version::from_str(result.version).with_context(|| {
×
449
            "Failed to parse `ext-php-rs` version that your extension was compiled with"
×
450
        })?;
451

452
        if !cli_version.matches(&ext_version) {
×
453
            bail!("Extension was compiled with an incompatible version of `ext-php-rs` - Extension: {}, CLI: {}", ext_version, cli_version);
×
454
        }
455

456
        let stubs = result
×
457
            .module
×
458
            .to_stub()
459
            .with_context(|| "Failed to generate stubs.")?;
×
460

461
        if self.stdout {
×
462
            print!("{stubs}");
×
463
        } else {
464
            let out_path = if let Some(out_path) = &self.out {
×
465
                Cow::Borrowed(out_path)
×
466
            } else {
467
                let mut cwd = std::env::current_dir()
×
468
                    .with_context(|| "Failed to get current working directory")?;
×
469
                cwd.push(format!("{}.stubs.php", result.module.name));
×
470
                Cow::Owned(cwd)
×
471
            };
472

473
            std::fs::write(out_path.as_ref(), &stubs)
×
474
                .with_context(|| "Failed to write stubs to file")?;
×
475
        }
476

477
        Ok(())
×
478
    }
479
}
480

481
/// Attempts to find an extension in the target directory.
482
fn find_ext(manifest: Option<&PathBuf>) -> AResult<cargo_metadata::Target> {
×
483
    // TODO(david): Look for cargo manifest option or env
484
    let mut cmd = cargo_metadata::MetadataCommand::new();
×
485
    if let Some(manifest) = manifest {
×
486
        cmd.manifest_path(manifest);
×
487
    }
488

489
    let meta = cmd
×
490
        .features(cargo_metadata::CargoOpt::AllFeatures)
×
491
        .exec()
492
        .with_context(|| "Failed to call `cargo metadata`")?;
×
493

494
    let package = meta
×
495
        .root_package()
496
        .with_context(|| "Failed to retrieve metadata about crate")?;
×
497

498
    let targets: Vec<_> = package
×
499
        .targets
×
500
        .iter()
501
        .filter(|target| {
×
502
            target
×
503
                .crate_types
×
504
                .iter()
×
505
                .any(|ty| ty == &CrateType::DyLib || ty == &CrateType::CDyLib)
×
506
        })
507
        .collect();
508

509
    let target = match targets.len() {
×
510
        0 => bail!("No library targets were found."),
×
511
        1 => targets[0],
×
512
        _ => {
513
            let target_names: Vec<_> = targets.iter().map(|target| &target.name).collect();
×
514
            let chosen = Select::new()
×
515
                .with_prompt("There were multiple library targets detected in the project. Which would you like to use?")
516
                .items(&target_names)
×
517
                .interact()?;
518
            targets[chosen]
×
519
        }
520
    };
521

522
    Ok(target.clone())
×
523
}
524

525
/// Compiles the extension, searching for the given target artifact. If found,
526
/// the path to the extension dynamic library is returned.
527
///
528
/// # Parameters
529
///
530
/// * `target` - The target to compile.
531
/// * `release` - Whether to compile the target in release mode.
532
/// * `features` - Optional list of features.
533
///
534
/// # Returns
535
///
536
/// The path to the target artifact.
537
fn build_ext(
×
538
    target: &Target,
539
    release: bool,
540
    features: Option<Vec<String>>,
541
    all_features: bool,
542
    no_default_features: bool,
543
) -> AResult<Utf8PathBuf> {
544
    let mut cmd = Command::new("cargo");
×
545
    cmd.arg("build")
×
546
        .arg("--message-format=json-render-diagnostics");
547
    if release {
×
548
        cmd.arg("--release");
×
549
    }
550
    if let Some(features) = features {
×
551
        cmd.arg("--features");
×
552
        for feature in features {
×
553
            cmd.arg(feature);
×
554
        }
555
    }
556

557
    if all_features {
×
558
        cmd.arg("--all-features");
×
559
    }
560

561
    if no_default_features {
×
562
        cmd.arg("--no-default-features");
×
563
    }
564

565
    let mut spawn = cmd
×
566
        .stdout(Stdio::piped())
×
567
        .spawn()
568
        .with_context(|| "Failed to spawn `cargo build`")?;
×
569
    let reader = BufReader::new(
570
        spawn
×
571
            .stdout
×
572
            .take()
×
573
            .with_context(|| "Failed to take `cargo build` stdout")?,
×
574
    );
575

576
    let mut artifact = None;
×
577
    for message in cargo_metadata::Message::parse_stream(reader) {
×
578
        let message = message.with_context(|| "Invalid message received from `cargo build`")?;
×
579
        match message {
×
580
            cargo_metadata::Message::CompilerArtifact(a) => {
×
581
                if &a.target == target {
×
582
                    artifact = Some(a);
×
583
                }
584
            }
585
            cargo_metadata::Message::BuildFinished(b) => {
×
586
                if b.success {
×
587
                    break;
×
588
                }
589

590
                bail!("Compilation failed, cancelling installation.")
×
591
            }
592
            _ => {}
×
593
        }
594
    }
595

596
    let artifact = artifact.with_context(|| "Extension artifact was not compiled")?;
×
597
    for file in artifact.filenames {
×
598
        if file.extension() == Some(std::env::consts::DLL_EXTENSION) {
×
599
            return Ok(file);
×
600
        }
601
    }
602

603
    bail!("Failed to retrieve extension path from artifact")
×
604
}
605

606
/// Write content to a given filepath.
607
///
608
/// We may not have write permission but we may have sudo privilege on unix. So
609
/// we write to a temp file and then try moving it to given filepath, and retry
610
/// with sudo on unix.
NEW
611
fn write_to_file(content: String, filepath: &PathBuf) -> anyhow::Result<()> {
×
612
    // write to a temp file
NEW
613
    let tempf = std::env::temp_dir().join("__tmp_cargo_php");
×
NEW
614
    std::fs::write(&tempf, content)?;
×
615

616
    // Now try moving, `rename` will overwrite existing file.
NEW
617
    if std::fs::rename(&tempf, filepath).is_err() {
×
618
        #[cfg(unix)]
619
        {
620
            // if not successful, try with sudo on unix.
NEW
621
            let s = std::process::Command::new("sudo")
×
622
                .arg("mv")
NEW
623
                .arg(&tempf)
×
NEW
624
                .arg(filepath)
×
625
                .status()?;
NEW
626
            anyhow::ensure!(s.success(), "Falied to write to {filepath:?}");
×
627
        }
628

629
        #[cfg(not(unix))]
630
        anyhow::bail!("failed to write to {filepath:?}");
631
    }
632

NEW
633
    Ok(())
×
634
}
635

636
#[cfg(unix)]
NEW
637
fn is_root() -> bool {
×
NEW
638
    let uid = unsafe { libc::getuid() };
×
NEW
639
    let euid = unsafe { libc::geteuid() };
×
640

NEW
641
    match (uid, euid) {
×
NEW
642
        (_, 0) => true, // suid set
×
NEW
643
        (_, _) => false,
×
644
    }
645
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc