diff --git a/index.d.ts b/index.d.ts index db17935..ee342f7 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1069,6 +1069,254 @@ export declare class ConfigEntries extends Iterator { next(value?: void): IteratorResult } +/** + * A structure to represent git credentials in libgit2. + * + * Create instances via the static factory methods: `default()`, `sshKeyFromAgent()`, + * `sshKey()`, `sshKeyFromMemory()`, `userpassPlaintext()`, `username()`, `credentialHelper()`. + */ +export declare class Cred { + /** + * Create a "default" credential usable for Negotiate mechanisms like NTLM or Kerberos authentication. + * + * @category Cred + * + * @signature + * ```ts + * class Cred { + * static default(): Cred; + * } + * ``` + * + * @returns {Cred} A new Cred instance. + * + * @example + * + * ```ts + * import { Cred } from 'es-git'; + * + * const cred = Cred.default(); + * ``` + */ + static default(): Cred + /** + * Create a new SSH key credential object used for querying an ssh-agent. + * + * The username specified is the username to authenticate. + * + * @category Cred + * + * @signature + * ```ts + * class Cred { + * static sshKeyFromAgent(username: string): Cred; + * } + * ``` + * + * @param {string} username - The username to authenticate. + * @returns {Cred} A new Cred instance. + * + * @example + * + * ```ts + * import { Cred } from 'es-git'; + * + * const cred = Cred.sshKeyFromAgent('git'); + * ``` + */ + static sshKeyFromAgent(username: string): Cred + /** + * Create a new passphrase-protected SSH key credential object from file paths. + * + * @category Cred + * + * @signature + * ```ts + * class Cred { + * static sshKey( + * username: string, + * publicKeyPath: string | null | undefined, + * privateKeyPath: string, + * passphrase?: string | null | undefined, + * ): Cred; + * } + * ``` + * + * @param {string} username - The username to authenticate. + * @param {string | null | undefined} publicKeyPath - Path to the public key file. If `null` or `undefined`, the public key is derived from the private key. + * @param {string} privateKeyPath - Path to the private key file. + * @param {string | null | undefined} [passphrase] - Passphrase for the private key, if any. + * @returns {Cred} A new Cred instance. + * + * @example + * + * ```ts + * import { Cred } from 'es-git'; + * + * const cred = Cred.sshKey('git', null, '/home/user/.ssh/id_ed25519', null); + * ``` + */ + static sshKey(username: string, publicKeyPath: string | undefined | null, privateKeyPath: string, passphrase?: string | undefined | null): Cred + /** + * Create a new SSH key credential object reading the keys from memory. + * + * @category Cred + * + * @signature + * ```ts + * class Cred { + * static sshKeyFromMemory( + * username: string, + * publicKey: string | null | undefined, + * privateKey: string, + * passphrase?: string | null | undefined, + * ): Cred; + * } + * ``` + * + * @param {string} username - The username to authenticate. + * @param {string | null | undefined} publicKey - The public key content. If `null` or `undefined`, the public key is derived from the private key. + * @param {string} privateKey - The private key content. + * @param {string | null | undefined} [passphrase] - Passphrase for the private key, if any. + * @returns {Cred} A new Cred instance. + * + * @example + * + * ```ts + * import { Cred } from 'es-git'; + * + * const privateKey = await fs.readFile('/home/user/.ssh/id_ed25519', 'utf-8'); + * const cred = Cred.sshKeyFromMemory('git', null, privateKey, null); + * ``` + */ + static sshKeyFromMemory(username: string, publicKey: string | undefined | null, privateKey: string, passphrase?: string | undefined | null): Cred + /** + * Create a new plain-text username and password credential object. + * + * @category Cred + * + * @signature + * ```ts + * class Cred { + * static userpassPlaintext(username: string, password: string): Cred; + * } + * ``` + * + * @param {string} username - The username to authenticate. + * @param {string} password - The password to authenticate. + * @returns {Cred} A new Cred instance. + * + * @example + * + * ```ts + * import { Cred } from 'es-git'; + * + * const cred = Cred.userpassPlaintext('user', 'password'); + * ``` + */ + static userpassPlaintext(username: string, password: string): Cred + /** + * Attempt to read `credential.helper` according to gitcredentials(7). + * + * This function will attempt to parse the user's `credential.helper` configuration, + * invoke the necessary processes, and read off what the username/password should be + * for a particular URL. The returned credential will be a username/password credential + * if successful. + * + * @category Cred + * + * @signature + * ```ts + * class Cred { + * static credentialHelper( + * url: string, + * username?: string | null | undefined, + * ): Cred; + * } + * ``` + * + * @param {string} url - The URL to get credentials for. + * @param {string | null | undefined} [username] - Optional username hint. + * @returns {Cred} A new Cred instance containing the username/password from the helper. + * + * @example + * + * ```ts + * import { Cred } from 'es-git'; + * + * const cred = Cred.credentialHelper('https://github.com/user/repo'); + * ``` + */ + static credentialHelper(url: string, username?: string | undefined | null): Cred + /** + * Create a credential to specify a username. + * + * This is used with SSH authentication to query for the username if none is specified in the URL. + * + * @category Cred + * + * @signature + * ```ts + * class Cred { + * static username(username: string): Cred; + * } + * ``` + * + * @param {string} username - The username to authenticate. + * @returns {Cred} A new Cred instance. + * + * @example + * + * ```ts + * import { Cred } from 'es-git'; + * + * const cred = Cred.username('git'); + * ``` + */ + static username(username: string): Cred + /** + * Check whether a credential object contains username information. + * + * @category Cred/Methods + * + * @signature + * ```ts + * class Cred { + * hasUsername(): boolean; + * } + * ``` + * + * @returns {boolean} Returns `true` if this credential contains username information. + * + * @throws Throws if the underlying resource is no longer available (e.g. key file deleted, credential helper config changed) since the credential was created. + */ + hasUsername(): boolean + /** + * Return the type of credentials that this object represents. + * + * @category Cred/Methods + * + * @signature + * ```ts + * class Cred { + * credtype(): CredType; + * } + * ``` + * + * @returns {CredType} The type of this credential. + * + * @example + * + * ```ts + * import { Cred } from 'es-git'; + * + * const cred = Cred.userpassPlaintext('user', 'password'); + * console.log(cred.credtype()); // 'UserpassPlaintext' + * ``` + */ + credtype(): CredType +} + /** * An iterator over the diffs in a delta. * @@ -7196,35 +7444,24 @@ export interface CreateTagOptions { force?: boolean } -/** A interface to represent git credentials in libgit2. */ -export type Credential = { - type: 'Default'; -} | { - type: 'SSHKeyFromAgent'; - username?: string; -} | { - type: 'SSHKeyFromPath'; - username?: string; - publicKeyPath?: string; - privateKeyPath: string; - passphrase?: string; -} | { - type: 'SSHKey'; - username?: string; - publicKey?: string; - privateKey: string; - passphrase?: string; -} | { - type: 'Plain'; - username?: string; - password: string; -}; - -export type CredentialType = 'Default'| -'SSHKeyFromAgent'| -'SSHKeyFromPath'| -'SSHKey'| -'Plain'; +/** + * - `UserpassPlaintext` : Username and password in plain text. + * - `SshKey` : SSH key (from file). + * - `SshCustom` : SSH key with custom signature. + * - `Default` : Default (e.g. NTLM, Kerberos). + * - `SshInteractive` : SSH interactive. + * - `Username` : Username only. + * - `SshMemory` : SSH key from memory. + */ +export declare enum CredType { + UserpassPlaintext = 0, + SshKey = 1, + SshCustom = 2, + Default = 3, + SshInteractive = 4, + Username = 5, + SshMemory = 6 +} export interface DeleteNoteOptions { /** @@ -7635,7 +7872,7 @@ export interface ExtractedSignature { } export interface FetchOptions { - credential?: Credential + credential?: Cred /** Set the proxy options to use for the fetch operation. */ proxy?: ProxyOptions /** Set whether to perform a prune after the fetch. */ @@ -8423,12 +8660,13 @@ export interface ProxyOptions { } export interface PruneOptions { - credential?: Credential + credential?: Cred } /** Options to control the behavior of a git push. */ export interface PushOptions { - credential?: Credential + /** Set credential for the push operation. */ + credential?: Cred /** Set the proxy options to use for the push operation. */ proxy?: ProxyOptions /** diff --git a/index.js b/index.js index e25c710..8246e40 100644 --- a/index.js +++ b/index.js @@ -582,6 +582,7 @@ module.exports.Branches = nativeBinding.Branches module.exports.Commit = nativeBinding.Commit module.exports.Config = nativeBinding.Config module.exports.ConfigEntries = nativeBinding.ConfigEntries +module.exports.Cred = nativeBinding.Cred module.exports.Deltas = nativeBinding.Deltas module.exports.Describe = nativeBinding.Describe module.exports.Diff = nativeBinding.Diff @@ -621,7 +622,7 @@ module.exports.cloneRepository = nativeBinding.cloneRepository module.exports.ConfigLevel = nativeBinding.ConfigLevel module.exports.createMailmapFromBuffer = nativeBinding.createMailmapFromBuffer module.exports.createSignature = nativeBinding.createSignature -module.exports.CredentialType = nativeBinding.CredentialType +module.exports.CredType = nativeBinding.CredType module.exports.DeltaType = nativeBinding.DeltaType module.exports.DiffFlags = nativeBinding.DiffFlags module.exports.diffFlagsContains = nativeBinding.diffFlagsContains diff --git a/src/cred.rs b/src/cred.rs new file mode 100644 index 0000000..3975732 --- /dev/null +++ b/src/cred.rs @@ -0,0 +1,441 @@ +use napi::bindgen_prelude::*; +use napi_derive::napi; +use std::path::Path; + +#[napi] +#[repr(u8)] +#[derive(Copy, Clone)] +/// - `UserpassPlaintext` : Username and password in plain text. +/// - `SshKey` : SSH key (from file). +/// - `SshCustom` : SSH key with custom signature. +/// - `Default` : Default (e.g. NTLM, Kerberos). +/// - `SshInteractive` : SSH interactive. +/// - `Username` : Username only. +/// - `SshMemory` : SSH key from memory. +pub enum CredType { + UserpassPlaintext, + SshKey, + SshCustom, + Default, + SshInteractive, + Username, + SshMemory, +} + +impl From for CredType { + fn from(bits: u32) -> Self { + let cred_type = git2::CredentialType::from_bits_truncate(bits); + if cred_type.contains(git2::CredentialType::USER_PASS_PLAINTEXT) { + return Self::UserpassPlaintext; + } + if cred_type.contains(git2::CredentialType::SSH_KEY) { + return Self::SshKey; + } + if cred_type.contains(git2::CredentialType::SSH_CUSTOM) { + return Self::SshCustom; + } + if cred_type.contains(git2::CredentialType::DEFAULT) { + return Self::Default; + } + if cred_type.contains(git2::CredentialType::SSH_INTERACTIVE) { + return Self::SshInteractive; + } + if cred_type.contains(git2::CredentialType::USERNAME) { + return Self::Username; + } + if cred_type.contains(git2::CredentialType::SSH_MEMORY) { + return Self::SshMemory; + } + Self::Default + } +} + +/// Stores the parameters needed to construct a `git2::Cred` on demand. +/// Call `to_git2_cred()` to materialise the credential at the desired point in time. +#[derive(Clone)] +pub(crate) enum CredRecipe { + Default, + SshKeyFromAgent { + username: String, + }, + SshKey { + username: String, + public_key_path: Option, + private_key_path: String, + passphrase: Option, + }, + SshKeyFromMemory { + username: String, + public_key: Option, + private_key: String, + passphrase: Option, + }, + UserpassPlaintext { + username: String, + password: String, + }, + Username { + username: String, + }, + CredentialHelper { + url: String, + username: Option, + }, +} + +#[napi] +#[derive(Clone)] +/// A structure to represent git credentials in libgit2. +/// +/// Create instances via the static factory methods: `default()`, `sshKeyFromAgent()`, +/// `sshKey()`, `sshKeyFromMemory()`, `userpassPlaintext()`, `username()`, `credentialHelper()`. +pub struct Cred { + pub(crate) recipe: CredRecipe, +} + +// Required so that `Cred` can be used as a field inside `#[napi(object)]` structs (e.g. `FetchOptions`). +// napi-rs does not auto-generate `FromNapiValue` for `#[napi]` classes, so we implement it manually. +// Because `Cred: Clone`, we extract the raw pointer via `napi_unwrap` and clone the value. +impl FromNapiValue for Cred { + unsafe fn from_napi_value(env: napi::sys::napi_env, napi_val: napi::sys::napi_value) -> napi::Result { + let mut ptr = std::ptr::null_mut::(); + check_status!( + napi::sys::napi_unwrap(env, napi_val, &mut ptr), + "Failed to unwrap `Cred` from JS value" + )?; + Ok((*(ptr as *const Cred)).clone()) + } +} + +// Crate-internal methods: not exposed to JS. Converts the stored recipe into a live `git2::Cred`. +impl Cred { + pub(crate) fn to_git2_cred(&self) -> std::result::Result { + match &self.recipe { + CredRecipe::Default => git2::Cred::default(), + CredRecipe::SshKeyFromAgent { username } => git2::Cred::ssh_key_from_agent(username), + CredRecipe::SshKey { + username, + public_key_path, + private_key_path, + passphrase, + } => git2::Cred::ssh_key( + username, + public_key_path.as_deref().map(Path::new), + Path::new(private_key_path), + passphrase.as_deref(), + ), + CredRecipe::SshKeyFromMemory { + username, + public_key, + private_key, + passphrase, + } => git2::Cred::ssh_key_from_memory(username, public_key.as_deref(), private_key, passphrase.as_deref()), + CredRecipe::UserpassPlaintext { username, password } => git2::Cred::userpass_plaintext(username, password), + CredRecipe::Username { username } => git2::Cred::username(username), + CredRecipe::CredentialHelper { url, username } => { + let config = git2::Config::open_default()?; + git2::Cred::credential_helper(&config, url, username.as_deref()) + } + } + } +} + +#[napi] +impl Cred { + #[napi(js_name = "default")] + /// Create a "default" credential usable for Negotiate mechanisms like NTLM or Kerberos authentication. + /// + /// @category Cred + /// + /// @signature + /// ```ts + /// class Cred { + /// static default(): Cred; + /// } + /// ``` + /// + /// @returns {Cred} A new Cred instance. + /// + /// @example + /// + /// ```ts + /// import { Cred } from 'es-git'; + /// + /// const cred = Cred.default(); + /// ``` + pub fn create_default() -> Cred { + Cred { + recipe: CredRecipe::Default, + } + } + + #[napi] + /// Create a new SSH key credential object used for querying an ssh-agent. + /// + /// The username specified is the username to authenticate. + /// + /// @category Cred + /// + /// @signature + /// ```ts + /// class Cred { + /// static sshKeyFromAgent(username: string): Cred; + /// } + /// ``` + /// + /// @param {string} username - The username to authenticate. + /// @returns {Cred} A new Cred instance. + /// + /// @example + /// + /// ```ts + /// import { Cred } from 'es-git'; + /// + /// const cred = Cred.sshKeyFromAgent('git'); + /// ``` + pub fn ssh_key_from_agent(username: String) -> Cred { + Cred { + recipe: CredRecipe::SshKeyFromAgent { username }, + } + } + + #[napi] + /// Create a new passphrase-protected SSH key credential object from file paths. + /// + /// @category Cred + /// + /// @signature + /// ```ts + /// class Cred { + /// static sshKey( + /// username: string, + /// publicKeyPath: string | null | undefined, + /// privateKeyPath: string, + /// passphrase?: string | null | undefined, + /// ): Cred; + /// } + /// ``` + /// + /// @param {string} username - The username to authenticate. + /// @param {string | null | undefined} publicKeyPath - Path to the public key file. If `null` or `undefined`, the public key is derived from the private key. + /// @param {string} privateKeyPath - Path to the private key file. + /// @param {string | null | undefined} [passphrase] - Passphrase for the private key, if any. + /// @returns {Cred} A new Cred instance. + /// + /// @example + /// + /// ```ts + /// import { Cred } from 'es-git'; + /// + /// const cred = Cred.sshKey('git', null, '/home/user/.ssh/id_ed25519', null); + /// ``` + pub fn ssh_key( + username: String, + public_key_path: Option, + private_key_path: String, + passphrase: Option, + ) -> Cred { + Cred { + recipe: CredRecipe::SshKey { + username, + public_key_path, + private_key_path, + passphrase, + }, + } + } + + #[napi] + /// Create a new SSH key credential object reading the keys from memory. + /// + /// @category Cred + /// + /// @signature + /// ```ts + /// class Cred { + /// static sshKeyFromMemory( + /// username: string, + /// publicKey: string | null | undefined, + /// privateKey: string, + /// passphrase?: string | null | undefined, + /// ): Cred; + /// } + /// ``` + /// + /// @param {string} username - The username to authenticate. + /// @param {string | null | undefined} publicKey - The public key content. If `null` or `undefined`, the public key is derived from the private key. + /// @param {string} privateKey - The private key content. + /// @param {string | null | undefined} [passphrase] - Passphrase for the private key, if any. + /// @returns {Cred} A new Cred instance. + /// + /// @example + /// + /// ```ts + /// import { Cred } from 'es-git'; + /// + /// const privateKey = await fs.readFile('/home/user/.ssh/id_ed25519', 'utf-8'); + /// const cred = Cred.sshKeyFromMemory('git', null, privateKey, null); + /// ``` + pub fn ssh_key_from_memory( + username: String, + public_key: Option, + private_key: String, + passphrase: Option, + ) -> Cred { + Cred { + recipe: CredRecipe::SshKeyFromMemory { + username, + public_key, + private_key, + passphrase, + }, + } + } + + #[napi] + /// Create a new plain-text username and password credential object. + /// + /// @category Cred + /// + /// @signature + /// ```ts + /// class Cred { + /// static userpassPlaintext(username: string, password: string): Cred; + /// } + /// ``` + /// + /// @param {string} username - The username to authenticate. + /// @param {string} password - The password to authenticate. + /// @returns {Cred} A new Cred instance. + /// + /// @example + /// + /// ```ts + /// import { Cred } from 'es-git'; + /// + /// const cred = Cred.userpassPlaintext('user', 'password'); + /// ``` + pub fn userpass_plaintext(username: String, password: String) -> Cred { + Cred { + recipe: CredRecipe::UserpassPlaintext { username, password }, + } + } + + #[napi] + /// Attempt to read `credential.helper` according to gitcredentials(7). + /// + /// This function will attempt to parse the user's `credential.helper` configuration, + /// invoke the necessary processes, and read off what the username/password should be + /// for a particular URL. The returned credential will be a username/password credential + /// if successful. + /// + /// @category Cred + /// + /// @signature + /// ```ts + /// class Cred { + /// static credentialHelper( + /// url: string, + /// username?: string | null | undefined, + /// ): Cred; + /// } + /// ``` + /// + /// @param {string} url - The URL to get credentials for. + /// @param {string | null | undefined} [username] - Optional username hint. + /// @returns {Cred} A new Cred instance containing the username/password from the helper. + /// + /// @example + /// + /// ```ts + /// import { Cred } from 'es-git'; + /// + /// const cred = Cred.credentialHelper('https://github.com/user/repo'); + /// ``` + pub fn credential_helper(url: String, username: Option) -> Cred { + Cred { + recipe: CredRecipe::CredentialHelper { url, username }, + } + } + + #[napi] + /// Create a credential to specify a username. + /// + /// This is used with SSH authentication to query for the username if none is specified in the URL. + /// + /// @category Cred + /// + /// @signature + /// ```ts + /// class Cred { + /// static username(username: string): Cred; + /// } + /// ``` + /// + /// @param {string} username - The username to authenticate. + /// @returns {Cred} A new Cred instance. + /// + /// @example + /// + /// ```ts + /// import { Cred } from 'es-git'; + /// + /// const cred = Cred.username('git'); + /// ``` + pub fn username(username: String) -> Cred { + Cred { + recipe: CredRecipe::Username { username }, + } + } + + #[napi] + /// Check whether a credential object contains username information. + /// + /// @category Cred/Methods + /// + /// @signature + /// ```ts + /// class Cred { + /// hasUsername(): boolean; + /// } + /// ``` + /// + /// @returns {boolean} Returns `true` if this credential contains username information. + /// + /// @throws Throws if the underlying resource is no longer available (e.g. key file deleted, credential helper config changed) since the credential was created. + pub fn has_username(&self) -> crate::Result { + Ok(self.to_git2_cred()?.has_username()) + } + + #[napi] + /// Return the type of credentials that this object represents. + /// + /// @category Cred/Methods + /// + /// @signature + /// ```ts + /// class Cred { + /// credtype(): CredType; + /// } + /// ``` + /// + /// @returns {CredType} The type of this credential. + /// + /// @example + /// + /// ```ts + /// import { Cred } from 'es-git'; + /// + /// const cred = Cred.userpassPlaintext('user', 'password'); + /// console.log(cred.credtype()); // 'UserpassPlaintext' + /// ``` + pub fn credtype(&self) -> CredType { + match &self.recipe { + CredRecipe::Default => CredType::Default, + CredRecipe::SshKeyFromAgent { .. } | CredRecipe::SshKey { .. } => CredType::SshKey, + CredRecipe::SshKeyFromMemory { .. } => CredType::SshMemory, + CredRecipe::UserpassPlaintext { .. } | CredRecipe::CredentialHelper { .. } => CredType::UserpassPlaintext, + CredRecipe::Username { .. } => CredType::Username, + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 5ccbcbf..f5fa65a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod checkout; pub mod cherrypick; pub mod commit; pub mod config; +pub mod cred; pub mod describe; pub mod diff; mod error; diff --git a/src/remote.rs b/src/remote.rs index 5adecd2..bb4ad32 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -1,7 +1,7 @@ +use crate::cred::Cred; use crate::repository::Repository; use napi::bindgen_prelude::*; use napi_derive::napi; -use std::path::Path; use std::sync::RwLock; #[napi(string_enum)] @@ -52,59 +52,6 @@ impl<'a> TryFrom> for Refspec { } } -#[napi(string_enum)] -#[derive(Copy, Clone)] -pub enum CredentialType { - Default, - SSHKeyFromAgent, - SSHKeyFromPath, - SSHKey, - Plain, -} - -#[napi(object)] -#[derive(Clone)] -/// A interface to represent git credentials in libgit2. -pub struct Credential { - pub r#type: CredentialType, - pub username: Option, - pub public_key_path: Option, - pub public_key: Option, - pub private_key_path: Option, - pub private_key: Option, - pub passphrase: Option, - pub password: Option, -} - -impl Credential { - pub(crate) fn to_git2_cred(&self) -> std::result::Result { - let fallback = "git".to_string(); - let cred = match self.r#type { - CredentialType::Default => git2::Cred::default(), - CredentialType::SSHKeyFromAgent => { - git2::Cred::ssh_key_from_agent(self.username.to_owned().unwrap_or(fallback).as_ref()) - } - CredentialType::SSHKeyFromPath => git2::Cred::ssh_key( - self.username.to_owned().unwrap_or(fallback).as_ref(), - self.public_key_path.as_ref().map(Path::new), - Path::new(&self.private_key_path.to_owned().unwrap()), - self.passphrase.as_ref().map(String::as_ref), - ), - CredentialType::SSHKey => git2::Cred::ssh_key_from_memory( - self.username.to_owned().unwrap_or(fallback).as_ref(), - self.public_key.as_ref().map(String::as_ref), - &self.private_key.to_owned().unwrap(), - self.passphrase.as_ref().map(String::as_ref), - ), - CredentialType::Plain => git2::Cred::userpass_plaintext( - self.username.to_owned().unwrap_or(fallback).as_ref(), - &self.password.to_owned().unwrap(), - ), - }?; - Ok(cred) - } -} - #[napi(object)] pub struct ProxyOptions { /// Try to auto-detect the proxy from the git configuration. @@ -198,7 +145,7 @@ impl From for git2::RemoteRedirect { #[napi(object)] pub struct FetchOptions { - pub credential: Option, + pub credential: Option, /// Set the proxy options to use for the fetch operation. pub proxy: Option, /// Set whether to perform a prune after the fetch. @@ -254,7 +201,8 @@ impl<'a> FetchOptions { #[napi(object)] /// Options to control the behavior of a git push. pub struct PushOptions { - pub credential: Option, + /// Set credential for the push operation. + pub credential: Option, /// Set the proxy options to use for the push operation. pub proxy: Option, /// If the transport being used to push to the remote requires the creation @@ -317,7 +265,7 @@ pub struct FetchRemoteOptions { #[napi(object)] pub struct PruneOptions { - pub credential: Option, + pub credential: Option, } pub struct FetchRemoteTask { @@ -413,6 +361,7 @@ impl Task for PruneRemoteTask { Some(PruneOptions { credential: Some(cred), .. }) => { + let cred = cred.clone(); let mut callbacks = git2::RemoteCallbacks::new(); callbacks.credentials(move |_url, _username, _cred| cred.to_git2_cred()); Some(callbacks) diff --git a/tests/cred.spec.ts b/tests/cred.spec.ts new file mode 100644 index 0000000..09c02d7 --- /dev/null +++ b/tests/cred.spec.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest'; +import { Cred, CredType } from '../index'; + +describe('cred', () => { + describe('default()', () => { + it('creates credential', () => { + const cred = Cred.default(); + expect(cred).toBeDefined(); + }); + + it('credtype is Default', () => { + const cred = Cred.default(); + expect(cred.credtype()).toBe(CredType.Default); + }); + + it('hasUsername returns false', () => { + const cred = Cred.default(); + expect(cred.hasUsername()).toBe(false); + }); + }); + + describe('userpassPlaintext()', () => { + it('creates credential', () => { + const cred = Cred.userpassPlaintext('user', 'password'); + expect(cred).toBeDefined(); + }); + + it('credtype is UserpassPlaintext', () => { + const cred = Cred.userpassPlaintext('user', 'password'); + expect(cred.credtype()).toBe(CredType.UserpassPlaintext); + }); + + it('hasUsername returns true', () => { + const cred = Cred.userpassPlaintext('user', 'password'); + expect(cred.hasUsername()).toBe(true); + }); + + it('accepts empty string password', () => { + const cred = Cred.userpassPlaintext('user', ''); + expect(cred.credtype()).toBe(CredType.UserpassPlaintext); + }); + }); + + describe('username()', () => { + it('creates credential', () => { + const cred = Cred.username('git'); + expect(cred).toBeDefined(); + }); + + it('credtype is Username', () => { + const cred = Cred.username('git'); + expect(cred.credtype()).toBe(CredType.Username); + }); + + it('accepts arbitrary username', () => { + const cred = Cred.username('my-github-user'); + expect(cred.credtype()).toBe(CredType.Username); + expect(cred.hasUsername()).toBe(true); + }); + }); + + describe('sshKeyFromAgent()', () => { + it('creates credential or throws when no agent', () => { + try { + const cred = Cred.sshKeyFromAgent('git'); + expect(cred.credtype()).toBeDefined(); + expect(cred.hasUsername()).toBe(true); + } catch (e) { + expect(e).toBeDefined(); + } + }); + }); + + describe('sshKey()', () => { + // libgit2 defers key file I/O until the credential is actually used, + // so path-based tests can use nonexistent paths to verify the binding. + it('creates credential without publicKeyPath (derived from private key)', () => { + const cred = Cred.sshKey('git', null, '/path/to/id_ed25519', null); + expect(cred).toBeDefined(); + expect(cred.credtype()).toBe(CredType.SshKey); + expect(cred.hasUsername()).toBe(true); + }); + + it('creates credential with publicKeyPath provided', () => { + const cred = Cred.sshKey('git', '/path/to/id_ed25519.pub', '/path/to/id_ed25519', null); + expect(cred.credtype()).toBe(CredType.SshKey); + expect(cred.hasUsername()).toBe(true); + }); + + it('creates credential with both publicKeyPath and passphrase', () => { + const cred = Cred.sshKey('git', '/path/to/id_ed25519.pub', '/path/to/id_ed25519', 'my-passphrase'); + expect(cred.credtype()).toBe(CredType.SshKey); + }); + + it('creates credential without publicKeyPath but with passphrase', () => { + const cred = Cred.sshKey('git', null, '/path/to/id_rsa', 'my-passphrase'); + expect(cred.credtype()).toBe(CredType.SshKey); + }); + }); + + describe('sshKeyFromMemory()', () => { + // libgit2 defers key content validation until the credential is actually used during + // authentication, so even truncated or invalid PEM does not throw at creation time. + const DUMMY_PRIVATE_KEY = [ + '-----BEGIN OPENSSH PRIVATE KEY-----', + 'b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW', + 'QyNTUxOQAAACD3', + '-----END OPENSSH PRIVATE KEY-----', + ].join('\n'); + + const DUMMY_PUBLIC_KEY = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDummyPublicKeyForTestingPurposesOnly user@host'; + + it('creates credential without publicKey (null)', () => { + try { + const cred = Cred.sshKeyFromMemory('git', null, DUMMY_PRIVATE_KEY, null); + expect(cred.credtype()).toBe(CredType.SshMemory); + expect(cred.hasUsername()).toBe(true); + } catch (e) { + // libgit2 may reject truncated key content + expect(e).toBeDefined(); + } + }); + + it('creates credential with publicKey provided', () => { + try { + const cred = Cred.sshKeyFromMemory('git', DUMMY_PUBLIC_KEY, DUMMY_PRIVATE_KEY, null); + expect(cred.credtype()).toBe(CredType.SshMemory); + expect(cred.hasUsername()).toBe(true); + } catch (e) { + expect(e).toBeDefined(); + } + }); + + it('creates credential with publicKey and passphrase', () => { + try { + const cred = Cred.sshKeyFromMemory('git', DUMMY_PUBLIC_KEY, DUMMY_PRIVATE_KEY, 'passphrase'); + expect(cred.credtype()).toBe(CredType.SshMemory); + } catch (e) { + expect(e).toBeDefined(); + } + }); + + it('creates credential without publicKey but with passphrase', () => { + try { + const cred = Cred.sshKeyFromMemory('git', null, DUMMY_PRIVATE_KEY, 'passphrase'); + expect(cred.credtype()).toBe(CredType.SshMemory); + } catch (e) { + expect(e).toBeDefined(); + } + }); + }); + + describe('credentialHelper()', () => { + it('creates credential', () => { + const cred = Cred.credentialHelper('https://example.invalid/repo', null); + expect(cred).toBeDefined(); + expect(cred.credtype()).toBe(CredType.UserpassPlaintext); + }); + + it('accepts username hint', () => { + const cred = Cred.credentialHelper('https://example.invalid/repo', 'user'); + expect(cred).toBeDefined(); + }); + }); + + describe('hasUsername()', () => { + it('returns false for Default credential', () => { + expect(Cred.default().hasUsername()).toBe(false); + }); + + it('returns true for userpassPlaintext credential', () => { + expect(Cred.userpassPlaintext('user', 'pass').hasUsername()).toBe(true); + }); + + it('returns true for username credential', () => { + expect(Cred.username('git').hasUsername()).toBe(true); + }); + + it('returns true for sshKey credential', () => { + expect(Cred.sshKey('git', null, '/path/to/key', null).hasUsername()).toBe(true); + }); + + it('factory succeeds but hasUsername() throws if ssh-agent is unavailable', () => { + // sshKeyFromAgent() itself never throws — it only stores the recipe. + // hasUsername() calls to_git2_cred() which queries the agent, and may throw. + const cred = Cred.sshKeyFromAgent('git'); + expect(cred).toBeDefined(); + try { + const result = cred.hasUsername(); + expect(result).toBe(true); + } catch (e) { + expect(e).toBeDefined(); + } + }); + + it('factory succeeds but hasUsername() throws if credential helper is not configured', () => { + // credentialHelper() itself never throws — it only stores url + username hint. + // hasUsername() calls to_git2_cred() which runs the helper, and may throw. + const cred = Cred.credentialHelper('https://example.invalid/repo', null); + expect(cred).toBeDefined(); + try { + const result = cred.hasUsername(); + expect(result).toBe(true); + } catch (e) { + expect(e).toBeDefined(); + } + }); + }); + + describe('credtype()', () => { + it('returns Default for default()', () => { + expect(Cred.default().credtype()).toBe(CredType.Default); + }); + + it('returns UserpassPlaintext for userpassPlaintext()', () => { + expect(Cred.userpassPlaintext('user', 'pass').credtype()).toBe(CredType.UserpassPlaintext); + }); + + it('returns Username for username()', () => { + expect(Cred.username('git').credtype()).toBe(CredType.Username); + }); + + it('returns SshKey for sshKey()', () => { + expect(Cred.sshKey('git', null, '/path/to/key', null).credtype()).toBe(CredType.SshKey); + }); + }); +});