use clap::Parser;
use reqwest::{
blocking::Client,
header::{AUTHORIZATION, CONTENT_TYPE},
};
use serde_json::json;
use sha1::{Digest, Sha1};
use walkdir::WalkDir;
use crate::{
RUST_ROOT, buckal_error, buckal_log,
config::Config,
registry::{
SessionCompleteRequest, SessionCompleteResponse, SessionFileResponse, SessionManifestFile,
SessionManifestRequest, SessionManifestResponse, SessionStartRequest, SessionStartResponse,
},
utils::{UnwrapOrExit, get_buck2_root},
};
#[derive(Parser, Debug)]
pub struct PushArgs {
#[arg(long)]
pub registry: Option<String>,
#[arg(long, short)]
pub message: Option<String>,
}
pub fn execute(args: &PushArgs) {
let mut config = Config::load();
let registry_name = args
.registry
.as_deref()
.unwrap_or_else(|| config.default_registry())
.to_string();
if let Some(registry) = config.registries.get_mut(®istry_name) {
if let Some(token) = ®istry.token {
let client = Client::new();
let start_request = SessionStartRequest {
path: "/".to_string(),
};
let response: SessionStartResponse = client
.post(format!("{}/api/v1/buck/session/start", registry.api))
.body(json!(start_request).to_string())
.header(AUTHORIZATION, format!("Bearer {}", token))
.header(CONTENT_TYPE, "application/json")
.send()
.unwrap_or_exit_ctx("failed to start session")
.json()
.unwrap_or_exit();
if !response.req_result {
buckal_error!("failed to start session: {}", response.err_message);
std::process::exit(1);
}
let cl_link = response.data.cl_link;
buckal_log!("Push", format!("session started. Change List: {}", cl_link));
let mut manifest = SessionManifestRequest {
commit_message: Some(
args.message
.as_deref()
.unwrap_or("Update third-party BUCK files")
.to_string(),
),
files: vec![],
};
let buck2_root = get_buck2_root().unwrap_or_exit();
let third_party_dir = buck2_root.join(RUST_ROOT);
for entry in WalkDir::new(&third_party_dir).into_iter() {
let entry = entry.unwrap_or_exit_ctx("failed to read third-party directory");
let entry_path = entry.path();
if entry_path.is_file() && entry_path.file_name().unwrap() == "BUCK" {
let file_content = std::fs::read(entry_path).unwrap_or_exit();
let file_size = file_content.len() as i64;
let file_hash = Sha1::digest(file_content);
let relative_path = entry_path
.strip_prefix(&buck2_root)
.unwrap_or_exit_ctx("failed to resolve relative path")
.to_string_lossy()
.into_owned();
manifest.files.push(SessionManifestFile {
path: relative_path,
size: file_size,
hash: format!("sha1:{}", hex::encode(file_hash)),
});
}
}
let response: SessionManifestResponse = client
.post(format!(
"{}/api/v1/buck/session/{}/manifest",
registry.api, cl_link
))
.body(json!(manifest).to_string())
.header(AUTHORIZATION, format!("Bearer {}", token))
.header(CONTENT_TYPE, "application/json")
.send()
.unwrap_or_exit_ctx("failed to upload manifest")
.json()
.unwrap_or_exit();
if !response.req_result {
buckal_error!("failed to upload manifest: {}", response.err_message);
std::process::exit(1);
}
let files_to_upload = response.data.files_to_upload;
if files_to_upload.is_empty() {
buckal_log!("Push", "no files need to be uploaded");
} else {
buckal_log!(
"Push",
format!("{} files need to be uploaded", files_to_upload.len())
);
for file in files_to_upload {
if !is_safe_subpath(&file.path) {
buckal_error!(
"path traversal detected: `{}` escapes `{}`",
file.path,
buck2_root
);
std::process::exit(1);
}
let full_path = buck2_root.join(&file.path);
let file_content = std::fs::read(&full_path).unwrap_or_exit();
let file_size = file_content.len() as i64;
buckal_log!("Uploading", &file.path);
let response: SessionFileResponse = client
.post(format!(
"{}/api/v1/buck/session/{}/file",
registry.api, &cl_link
))
.body(file_content)
.header(AUTHORIZATION, format!("Bearer {}", token))
.header(CONTENT_TYPE, "application/octet-stream")
.header("X-File-Path", &file.path)
.header("X-File-Size", file_size.to_string())
.send()
.unwrap_or_exit_ctx(format!("failed to upload file {}", file.path))
.json()
.unwrap_or_exit();
if !response.req_result {
buckal_error!(
"failed to upload file {}: {}",
file.path,
response.err_message
);
std::process::exit(1);
}
}
}
let response: SessionCompleteResponse = client
.post(format!(
"{}/api/v1/buck/session/{}/complete",
registry.api, cl_link
))
.body(
json!(SessionCompleteRequest {
commit_message: None,
})
.to_string(),
)
.header(AUTHORIZATION, format!("Bearer {}", token))
.header(CONTENT_TYPE, "application/json")
.send()
.unwrap_or_exit_ctx("failed to complete session")
.json()
.unwrap_or_exit();
if !response.req_result {
buckal_error!("failed to complete session: {}", response.err_message);
std::process::exit(1);
}
buckal_log!("Push", "session completed successfully");
} else {
buckal_error!("no token found, please run `cargo buckal login` first");
std::process::exit(1);
}
} else {
buckal_error!("registry `{}` not found in configuration", registry_name);
std::process::exit(1);
}
}
fn is_safe_subpath(target: &str) -> bool {
if target.starts_with('/') || target.starts_with('\\') {
return false;
}
if target.contains(':') {
return false;
}
let mut depth: i32 = 0;
for part in target.split(['/', '\\']) {
match part {
"" | "." => {
}
".." => {
depth -= 1;
if depth < 0 {
return false;
}
}
_ => {
depth += 1;
}
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_safe_relative_paths() {
assert!(is_safe_subpath("file.txt"));
assert!(is_safe_subpath("subdir/file.txt"));
assert!(is_safe_subpath("nested/deep/file.txt"));
assert!(is_safe_subpath("a/b/c/d/e.txt"));
}
#[test]
fn test_path_with_dot_components() {
assert!(is_safe_subpath("./file.txt"));
assert!(is_safe_subpath("subdir/./file.txt"));
assert!(is_safe_subpath("./subdir/./file.txt"));
}
#[test]
fn test_single_dot_dot_escape() {
assert!(!is_safe_subpath("../file.txt"));
assert!(!is_safe_subpath("subdir/../../file.txt"));
}
#[test]
fn test_multiple_dot_dot_escape() {
assert!(!is_safe_subpath("../../file.txt"));
assert!(!is_safe_subpath("../../../etc/passwd"));
assert!(!is_safe_subpath("a/b/c/../../../../file.txt"));
}
#[test]
fn test_absolute_paths_unix() {
assert!(!is_safe_subpath("/etc/passwd"));
assert!(!is_safe_subpath("/tmp/file.txt"));
assert!(!is_safe_subpath("/"));
}
#[test]
fn test_absolute_paths_windows() {
assert!(!is_safe_subpath("\\Windows\\System32"));
assert!(!is_safe_subpath("\\file.txt"));
}
#[test]
fn test_windows_drive_letters() {
assert!(!is_safe_subpath("C:\\Windows\\System32"));
assert!(!is_safe_subpath("D:\\file.txt"));
assert!(!is_safe_subpath("file:txt"));
}
#[test]
fn test_unc_paths() {
assert!(!is_safe_subpath("\\\\server\\share\\file.txt"));
assert!(!is_safe_subpath("./\\\\server:\\share"));
}
#[test]
fn test_safe_subdir_traversal() {
assert!(is_safe_subpath("subdir/file.txt"));
assert!(is_safe_subpath("a/b/c/file.txt"));
assert!(is_safe_subpath("deep/nested/path/file.txt"));
}
#[test]
fn test_safe_with_mixed_separators() {
assert!(is_safe_subpath("subdir/file.txt"));
assert!(is_safe_subpath("a\\b\\c\\file.txt"));
assert!(is_safe_subpath("mixed/path\\file.txt"));
}
#[test]
fn test_empty_and_dot_components() {
assert!(is_safe_subpath("."));
assert!(is_safe_subpath("a/./b"));
assert!(is_safe_subpath("a//b"));
}
#[test]
fn test_complex_escape_attempts() {
assert!(!is_safe_subpath("file/../../../../../../../etc/passwd"));
assert!(!is_safe_subpath("./a/../b/../../file.txt"));
assert!(!is_safe_subpath("legitimate/../../.."));
}
#[test]
fn test_dot_dot_at_boundary() {
assert!(!is_safe_subpath(".."));
assert!(!is_safe_subpath("a/b/c/../../../../"));
}
#[test]
fn test_hidden_files() {
assert!(is_safe_subpath(".hidden"));
assert!(is_safe_subpath("dir/.hidden"));
assert!(is_safe_subpath(".config/file.txt"));
}
}