feat: 0.3.0, supports meta compress, encrypted meta
This commit is contained in:
@@ -20,12 +20,9 @@ use zeroize::Zeroize;
|
||||
use crate::{card, file, util, util_piv};
|
||||
use crate::compress::GzStreamDecoder;
|
||||
use crate::config::TinyEncryptConfig;
|
||||
use crate::crypto_aes::aes_gcm_decrypt;
|
||||
use crate::spec::{TinyEncryptEnvelop, TinyEncryptEnvelopType, TinyEncryptMeta};
|
||||
use crate::util::{
|
||||
ENC_AES256_GCM_P256, ENC_AES256_GCM_P384, ENC_AES256_GCM_X25519,
|
||||
TINY_ENC_CONFIG_FILE, TINY_ENC_FILE_EXT,
|
||||
};
|
||||
use crate::crypto_aes::{aes_gcm_decrypt, try_aes_gcm_decrypt_with_salt};
|
||||
use crate::spec::{EncEncryptedMeta, TinyEncryptEnvelop, TinyEncryptEnvelopType, TinyEncryptMeta};
|
||||
use crate::util::{ENC_AES256_GCM_P256, ENC_AES256_GCM_P384, ENC_AES256_GCM_X25519, SALT_COMMENT, TINY_ENC_CONFIG_FILE, TINY_ENC_FILE_EXT};
|
||||
use crate::wrap_key::WrapKey;
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
@@ -41,6 +38,9 @@ pub struct CmdDecrypt {
|
||||
/// Remove source file
|
||||
#[arg(long, short = 'R')]
|
||||
pub remove_file: bool,
|
||||
/// Skip decrypt file
|
||||
#[arg(long)]
|
||||
pub skip_decrypt_file: bool,
|
||||
}
|
||||
|
||||
pub fn decrypt(cmd_decrypt: CmdDecrypt) -> XResult<()> {
|
||||
@@ -105,34 +105,48 @@ pub fn decrypt_single(config: &Option<TinyEncryptConfig>,
|
||||
debugging!("Decrypt key: {}", hex::encode(&key));
|
||||
debugging!("Decrypt nonce: {}", hex::encode(&nonce));
|
||||
|
||||
if let Some(enc_encrypted_meta) = &meta.encrypted_meta {
|
||||
let enc_encrypted_meta_bytes = opt_result!(
|
||||
util::decode_base64(enc_encrypted_meta), "Decode enc-encrypted-meta failed: {}");
|
||||
let enc_meta = opt_result!(
|
||||
EncEncryptedMeta::unseal(&key, &nonce, &enc_encrypted_meta_bytes), "Unseal enc-encrypted-meta failed: {}");
|
||||
if let Some(filename) = &enc_meta.filename {
|
||||
information!("Source filename: {}", filename);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(encrypted_comment) = &meta.encrypted_comment {
|
||||
match util::decode_base64(encrypted_comment) {
|
||||
Err(e) => warning!("Decode encrypted comment failed: {}", e),
|
||||
Ok(encrypted_comment_based_bytes) => match aes_gcm_decrypt(&key, &nonce, &encrypted_comment_based_bytes) {
|
||||
Ok(ec_bytes) => match try_aes_gcm_decrypt_with_salt(&key, &nonce, SALT_COMMENT, &ec_bytes) {
|
||||
Err(e) => warning!("Decrypt encrypted comment failed: {}", e),
|
||||
Ok(decrypted_comment_bytes) => match String::from_utf8(decrypted_comment_bytes.clone()) {
|
||||
Err(_) => success!("Encrypted message hex: {}", hex::encode(&decrypted_comment_bytes)),
|
||||
Ok(message) => success!("Encrypted message: {}", message),
|
||||
Ok(message) => success!("Encrypted comment: {}", message),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut file_out = File::create(path_out)?;
|
||||
if cmd_decrypt.skip_decrypt_file {
|
||||
information!("Decrypt file is skipped.");
|
||||
} else {
|
||||
let mut file_out = File::create(path_out)?;
|
||||
|
||||
let start = Instant::now();
|
||||
util_msg::print_lastline(
|
||||
&format!("Decrypting file: {}{} ...", path_display, iff!(meta.compress, " [compressed]", ""))
|
||||
);
|
||||
let _ = decrypt_file(&mut file_in, &mut file_out, &key, &nonce, meta.compress)?;
|
||||
util_msg::clear_lastline();
|
||||
let encrypt_duration = start.elapsed();
|
||||
debugging!("Encrypt file: {} elapsed: {} ms", path_display, encrypt_duration.as_millis());
|
||||
let start = Instant::now();
|
||||
util_msg::print_lastline(
|
||||
&format!("Decrypting file: {}{} ...", path_display, iff!(meta.compress, " [compressed]", ""))
|
||||
);
|
||||
let _ = decrypt_file(&mut file_in, &mut file_out, &key, &nonce, meta.compress)?;
|
||||
util_msg::clear_lastline();
|
||||
let encrypt_duration = start.elapsed();
|
||||
debugging!("Encrypt file: {} elapsed: {} ms", path_display, encrypt_duration.as_millis());
|
||||
drop(file_out);
|
||||
}
|
||||
|
||||
util::zeroize(key);
|
||||
util::zeroize(nonce);
|
||||
drop(file_in);
|
||||
drop(file_out);
|
||||
if cmd_decrypt.remove_file {
|
||||
match fs::remove_file(path) {
|
||||
Err(e) => warning!("Remove file: {} failed: {}", path_display, e),
|
||||
|
||||
@@ -7,16 +7,16 @@ use std::time::Instant;
|
||||
use clap::Args;
|
||||
use flate2::Compression;
|
||||
use rsa::Pkcs1v15Encrypt;
|
||||
use rust_util::{debugging, failure, information, opt_result, simple_error, success, util_msg, warning, XResult};
|
||||
use rust_util::{debugging, failure, iff, information, opt_result, simple_error, success, util_msg, warning, XResult};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use crate::{util, util_ecdh, util_p384, util_x25519};
|
||||
use crate::{compress, util, util_ecdh, util_p384, util_x25519};
|
||||
use crate::compress::GzStreamEncoder;
|
||||
use crate::config::{TinyEncryptConfig, TinyEncryptConfigEnvelop};
|
||||
use crate::crypto_aes::aes_gcm_encrypt;
|
||||
use crate::crypto_aes::{aes_gcm_encrypt, aes_gcm_encrypt_with_salt};
|
||||
use crate::crypto_rsa::parse_spki;
|
||||
use crate::spec::{EncMetadata, TINY_ENCRYPT_VERSION_10, TinyEncryptEnvelop, TinyEncryptEnvelopType, TinyEncryptMeta};
|
||||
use crate::util::{ENC_AES256_GCM_P256, ENC_AES256_GCM_P384, ENC_AES256_GCM_X25519, TINY_ENC_CONFIG_FILE};
|
||||
use crate::spec::{EncEncryptedMeta, EncMetadata, TINY_ENCRYPT_VERSION_10, TinyEncryptEnvelop, TinyEncryptEnvelopType, TinyEncryptMeta};
|
||||
use crate::util::{ENC_AES256_GCM_P256, ENC_AES256_GCM_P384, ENC_AES256_GCM_X25519, SALT_COMMENT, TINY_ENC_CONFIG_FILE};
|
||||
use crate::wrap_key::{WrapKey, WrapKeyHeader};
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
@@ -44,6 +44,9 @@ pub struct CmdEncrypt {
|
||||
/// Remove source file
|
||||
#[arg(long, short = 'R')]
|
||||
pub remove_file: bool,
|
||||
/// Disable compress meta
|
||||
#[arg(long)]
|
||||
pub disable_compress_meta: bool,
|
||||
}
|
||||
|
||||
pub fn encrypt(cmd_encrypt: CmdEncrypt) -> XResult<()> {
|
||||
@@ -115,14 +118,19 @@ fn encrypt_single(path: &PathBuf, envelops: &[&TinyEncryptConfigEnvelop], cmd_en
|
||||
let encrypted_comment = match &cmd_encrypt.encrypted_comment {
|
||||
None => None,
|
||||
Some(encrypted_comment) => Some(util::encode_base64(
|
||||
&aes_gcm_encrypt(&key, &nonce, encrypted_comment.as_bytes())?))
|
||||
&aes_gcm_encrypt_with_salt(&key, &nonce, SALT_COMMENT, encrypted_comment.as_bytes())?))
|
||||
};
|
||||
|
||||
let enc_encrypted_meta = EncEncryptedMeta {
|
||||
filename: Some(util::get_file_name(path)),
|
||||
};
|
||||
let enc_encrypted_meta_bytes = opt_result!(enc_encrypted_meta.seal(&key, &nonce), "Seal enc-encrypted-meta failed: {}");
|
||||
|
||||
let file_metadata = opt_result!(fs::metadata(path), "Read file: {} meta failed: {}", path.display());
|
||||
let enc_metadata = EncMetadata {
|
||||
comment: cmd_encrypt.comment.clone(),
|
||||
encrypted_comment,
|
||||
encrypted_meta: None,
|
||||
encrypted_meta: Some(util::encode_base64(&enc_encrypted_meta_bytes)),
|
||||
compress: cmd_encrypt.compress,
|
||||
};
|
||||
|
||||
@@ -150,10 +158,18 @@ fn encrypt_single(path: &PathBuf, envelops: &[&TinyEncryptConfigEnvelop], cmd_en
|
||||
}
|
||||
}
|
||||
|
||||
let compress_meta = !cmd_encrypt.disable_compress_meta;
|
||||
|
||||
let mut file_out = File::create(&path_out)?;
|
||||
opt_result!(file_out.write_all(&util::TINY_ENC_MAGIC_TAG.to_be_bytes()), "Write tag failed: {}");
|
||||
let encrypted_meta_bytes = opt_result!(serde_json::to_vec(&encrypt_meta), "Generate meta json bytes failed: {}");
|
||||
let tag = iff!(compress_meta, util::TINY_ENC_COMPRESSED_MAGIC_TAG, util::TINY_ENC_MAGIC_TAG);
|
||||
opt_result!(file_out.write_all(&tag.to_be_bytes()), "Write tag failed: {}");
|
||||
let mut encrypted_meta_bytes = opt_result!(serde_json::to_vec(&encrypt_meta), "Generate meta json bytes failed: {}");
|
||||
if compress_meta {
|
||||
encrypted_meta_bytes = opt_result!(
|
||||
compress::compress(Compression::default(), &encrypted_meta_bytes), "Compress encrypted meta failed: {}");
|
||||
}
|
||||
let encrypted_meta_bytes_len = encrypted_meta_bytes.len() as u32;
|
||||
debugging!("Encrypted meta len: {}", encrypted_meta_bytes_len);
|
||||
opt_result!(file_out.write_all(&encrypted_meta_bytes_len.to_be_bytes()), "Write meta len failed: {}");
|
||||
opt_result!(file_out.write_all(&encrypted_meta_bytes), "Write meta failed: {}");
|
||||
|
||||
@@ -281,7 +297,7 @@ fn encrypt_envelop_shared_secret(key: &[u8],
|
||||
|
||||
let wrap_key = WrapKey {
|
||||
header: WrapKeyHeader {
|
||||
kid: Some(envelop.kid.clone()),
|
||||
kid: None, // Some(envelop.kid.clone()),
|
||||
enc: enc_type.to_string(),
|
||||
e_pub_key: util::encode_base64_url_no_pad(&ephemeral_spki),
|
||||
},
|
||||
|
||||
@@ -4,6 +4,20 @@ use flate2::Compression;
|
||||
use flate2::write::{GzDecoder, GzEncoder};
|
||||
use rust_util::{simple_error, XResult};
|
||||
|
||||
pub fn compress(compression: Compression, message: &[u8]) -> XResult<Vec<u8>> {
|
||||
let mut encoder = GzStreamEncoder::new(compression);
|
||||
let mut buff = encoder.update(message)?;
|
||||
buff.extend_from_slice(&encoder.finalize()?);
|
||||
Ok(buff)
|
||||
}
|
||||
|
||||
pub fn decompress(message: &[u8]) -> XResult<Vec<u8>> {
|
||||
let mut decoder = GzStreamDecoder::new();
|
||||
let mut buff = decoder.update(message)?;
|
||||
buff.extend_from_slice(&decoder.finalize()?);
|
||||
Ok(buff)
|
||||
}
|
||||
|
||||
pub struct GzStreamEncoder {
|
||||
gz_encoder: GzEncoder<Vec<u8>>,
|
||||
}
|
||||
|
||||
@@ -1,25 +1,48 @@
|
||||
use aes_gcm_stream::{Aes256GcmStreamDecryptor, Aes256GcmStreamEncryptor};
|
||||
use rust_util::{opt_result, XResult};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
pub fn try_aes_gcm_decrypt_with_salt(key: &[u8], nonce: &[u8], salt: &[u8], message: &[u8]) -> XResult<Vec<u8>> {
|
||||
let new_nonce = build_salted_nonce(nonce, salt);
|
||||
if let Ok(decrypted) = aes_gcm_decrypt(key, &new_nonce, message) {
|
||||
return Ok(decrypted);
|
||||
}
|
||||
aes_gcm_decrypt(key, nonce, message)
|
||||
}
|
||||
|
||||
pub fn aes_gcm_decrypt(key: &[u8], nonce: &[u8], message: &[u8]) -> XResult<Vec<u8>> {
|
||||
let key: [u8; 32] = opt_result!(key.try_into(), "Invalid envelop: {}");
|
||||
let mut key: [u8; 32] = opt_result!(key.try_into(), "Invalid envelop: {}");
|
||||
let mut aes256_gcm = Aes256GcmStreamDecryptor::new(key, nonce);
|
||||
let mut b1 = aes256_gcm.update(message);
|
||||
let b2 = opt_result!(aes256_gcm.finalize(), "Invalid envelop: {}");
|
||||
b1.extend_from_slice(&b2);
|
||||
key.zeroize();
|
||||
Ok(b1)
|
||||
}
|
||||
|
||||
pub fn aes_gcm_encrypt_with_salt(key: &[u8], nonce: &[u8], salt: &[u8], message: &[u8]) -> XResult<Vec<u8>> {
|
||||
let new_nonce = build_salted_nonce(nonce, salt);
|
||||
aes_gcm_encrypt(key, &new_nonce, message)
|
||||
}
|
||||
|
||||
pub fn aes_gcm_encrypt(key: &[u8], nonce: &[u8], message: &[u8]) -> XResult<Vec<u8>> {
|
||||
let key: [u8; 32] = opt_result!(key.try_into(), "Invalid envelop: {}");
|
||||
let mut key: [u8; 32] = opt_result!(key.try_into(), "Invalid envelop: {}");
|
||||
let mut aes256_gcm = Aes256GcmStreamEncryptor::new(key, nonce);
|
||||
let mut b1 = aes256_gcm.update(message);
|
||||
let (b2, tag) = aes256_gcm.finalize();
|
||||
b1.extend_from_slice(&b2);
|
||||
b1.extend_from_slice(&tag);
|
||||
key.zeroize();
|
||||
Ok(b1)
|
||||
}
|
||||
|
||||
fn build_salted_nonce(nonce: &[u8], salt: &[u8]) -> Vec<u8> {
|
||||
let mut nonce_with_salt = nonce.to_vec();
|
||||
nonce_with_salt.extend_from_slice(salt);
|
||||
let input = hex::decode(sha256::digest(nonce_with_salt)).unwrap();
|
||||
input[0..12].to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aes_gcm_01() {
|
||||
let data_key = hex::decode("0001020304050607080910111213141516171819202122232425262728293031").unwrap();
|
||||
|
||||
15
src/file.rs
15
src/file.rs
@@ -1,9 +1,9 @@
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use rust_util::{opt_result, simple_error, XResult};
|
||||
use rust_util::{debugging, opt_result, simple_error, XResult};
|
||||
|
||||
use crate::spec::TinyEncryptMeta;
|
||||
use crate::util;
|
||||
use crate::{compress, util};
|
||||
|
||||
pub fn _write_tiny_encrypt_meta<W: Write>(w: &mut W, meta: &TinyEncryptMeta) -> XResult<usize> {
|
||||
let meta_json = opt_result!( serde_json::to_string(meta), "Meta to JSON failed: {}");
|
||||
@@ -27,7 +27,9 @@ pub fn read_tiny_encrypt_meta<R: Read>(r: &mut R) -> XResult<TinyEncryptMeta> {
|
||||
let mut tag_buff = [0_u8; 2];
|
||||
opt_result!(r.read_exact(&mut tag_buff), "Read tag failed: {}");
|
||||
let tag = u16::from_be_bytes(tag_buff);
|
||||
if tag != util::TINY_ENC_MAGIC_TAG {
|
||||
let is_normal_tiny_enc = tag == util::TINY_ENC_MAGIC_TAG;
|
||||
let is_compressed_tiny_enc = tag == util::TINY_ENC_COMPRESSED_MAGIC_TAG;
|
||||
if !is_normal_tiny_enc && !is_compressed_tiny_enc {
|
||||
return simple_error!("Tag is not 0x01, but is: 0x{:x}", tag);
|
||||
}
|
||||
|
||||
@@ -38,8 +40,15 @@ pub fn read_tiny_encrypt_meta<R: Read>(r: &mut R) -> XResult<TinyEncryptMeta> {
|
||||
return simple_error!("Meta too large: {}", length);
|
||||
}
|
||||
|
||||
debugging!("Encrypted meta len: {}", length);
|
||||
let mut meta_buff = vec![0; length as usize];
|
||||
opt_result!(r.read_exact(meta_buff.as_mut_slice()), "Read meta failed: {}");
|
||||
|
||||
debugging!("Tiny enc meta compressed: {}", is_compressed_tiny_enc);
|
||||
if is_compressed_tiny_enc {
|
||||
meta_buff = opt_result!(compress::decompress(&meta_buff), "Decompress meta failed: {}");
|
||||
}
|
||||
debugging!("Encrypted meta: {}", String::from_utf8_lossy(&meta_buff));
|
||||
|
||||
Ok(opt_result!(serde_json::from_slice(&meta_buff), "Parse meta failed: {}"))
|
||||
}
|
||||
|
||||
44
src/spec.rs
44
src/spec.rs
@@ -1,10 +1,12 @@
|
||||
use std::fs::Metadata;
|
||||
use flate2::Compression;
|
||||
|
||||
use rust_util::util_time;
|
||||
use rust_util::{opt_result, util_time, XResult};
|
||||
use rust_util::util_time::get_millis;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::{compress, crypto_aes};
|
||||
|
||||
use crate::util::{encode_base64, get_user_agent, TINY_ENC_AES_GCM};
|
||||
use crate::util::{encode_base64, get_user_agent, SALT_META, TINY_ENC_AES_GCM};
|
||||
|
||||
pub const TINY_ENCRYPT_VERSION_10: &'static str = "1.0";
|
||||
pub const TINY_ENCRYPT_VERSION_11: &'static str = "1.1";
|
||||
@@ -16,19 +18,30 @@ pub struct TinyEncryptMeta {
|
||||
pub version: String,
|
||||
pub created: u64,
|
||||
pub user_agent: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub comment: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub encrypted_comment: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub encrypted_meta: Option<String>,
|
||||
// ---------------------------------------
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pgp_envelop: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pgp_fingerprint: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub age_envelop: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub age_recipient: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ecdh_envelop: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ecdh_point: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub envelop: Option<String>,
|
||||
// ---------------------------------------
|
||||
pub envelops: Option<Vec<TinyEncryptEnvelop>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub encryption_algorithm: Option<String>,
|
||||
pub nonce: String,
|
||||
pub file_length: u64,
|
||||
@@ -41,6 +54,7 @@ pub struct TinyEncryptMeta {
|
||||
pub struct TinyEncryptEnvelop {
|
||||
pub r#type: TinyEncryptEnvelopType,
|
||||
pub kid: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub desc: Option<String>,
|
||||
pub encrypted_key: String,
|
||||
}
|
||||
@@ -78,6 +92,32 @@ impl TinyEncryptEnvelopType {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct EncEncryptedMeta {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub filename: Option<String>,
|
||||
}
|
||||
|
||||
impl EncEncryptedMeta {
|
||||
pub fn unseal(key: &[u8], nonce: &[u8], message: &[u8]) -> XResult<Self> {
|
||||
let mut decrypted = opt_result!(crypto_aes::try_aes_gcm_decrypt_with_salt(
|
||||
key, nonce, SALT_META, message), "Decrypt failed: {}");
|
||||
decrypted = opt_result!(compress::decompress(&decrypted), "Decode faield: {}");
|
||||
let meta = opt_result!(
|
||||
serde_json::from_slice::<Self>(&decrypted), "Parse failed: {}");
|
||||
return Ok(meta);
|
||||
}
|
||||
|
||||
pub fn seal(&self, key: &[u8], nonce: &[u8]) -> XResult<Vec<u8>> {
|
||||
let mut encrypted_meta_json = serde_json::to_vec(self).unwrap();
|
||||
encrypted_meta_json = opt_result!(
|
||||
compress::compress(Compression::default(), &encrypted_meta_json), "Compress failed: {}");
|
||||
let encrypted = opt_result!(crypto_aes::aes_gcm_encrypt_with_salt(
|
||||
key, nonce, SALT_META, encrypted_meta_json.as_slice()), "Encrypt failed: {}");
|
||||
Ok(encrypted)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EncMetadata {
|
||||
pub comment: Option<String>,
|
||||
pub encrypted_comment: Option<String>,
|
||||
|
||||
16
src/util.rs
16
src/util.rs
@@ -1,6 +1,6 @@
|
||||
use std::{fs, io};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose;
|
||||
@@ -17,6 +17,20 @@ pub const TINY_ENC_CONFIG_FILE: &str = "~/.tinyencrypt/config-rs.json";
|
||||
pub const TINY_ENC_AES_GCM: &str = "AES/GCM";
|
||||
|
||||
pub const TINY_ENC_MAGIC_TAG: u16 = 0x01;
|
||||
pub const TINY_ENC_COMPRESSED_MAGIC_TAG: u16 = 0x02;
|
||||
|
||||
pub const SALT_COMMENT: &[u8] = b"salt:comment";
|
||||
pub const SALT_META: &[u8] = b"salt:meta";
|
||||
|
||||
pub fn get_file_name(path: &PathBuf) -> String {
|
||||
let path_display = format!("{}", path.display());
|
||||
if path_display.contains("/") {
|
||||
if let Some(p) = path_display.split("/").last() {
|
||||
return p.to_string();
|
||||
}
|
||||
}
|
||||
path_display
|
||||
}
|
||||
|
||||
pub fn require_tiny_enc_file_and_exists(path: impl AsRef<Path>) -> XResult<()> {
|
||||
let path = path.as_ref();
|
||||
|
||||
Reference in New Issue
Block a user