diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5aa3ae2825b02dbbaaa50798c097b8a7b285a0c6 GIT binary patch literal 6148 zcmZQzU|@7AO)+F(5MW?n;9!8zEL;p&0Z1N%F(jFwBD^5;KxTO6TPS0vE z(BsUI$WX+P&XCWb$B@I2&ydWJjizT3UOgoY`3$)XIjDM=KxGfI+t?X=7(5xA8C)6s z7=jsGQB^ZIq1nL65X9ih;K<;@;LG63pvREQkb$5koSXsz=yWC6yN! zK+D1HNd-BX#U%y?*BF_YSyuu5=%;pof3CYb7CU|@iH zxh%LSFDE}Q9a6|MurZV{Kq531J;5*sp#=wu+Tjr>C|*Jf8fJzPhFpdM)HDK4d-K4V z=s!4U2M+;_d=Box=C0F@64&|=>KN;g1gkQ4(W0|TfU2GJ}qCo_Wj z0T4AHtsps&RuB!+%D@O>f#tzk85kKLS{cFJ5C#TDaF+x`gS9g-GJv%+FfxF(!)q)? zh;{}>h;{}>Xb*)E)T05J57ExR2+8Bmk}u|b%T efdQlyRPBSSVnzl?O+MNX0ENyd9u0wk2>}45EcJT; literal 0 HcmV?d00001 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22ef3b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# RustRover +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +tmp/ \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2cb726b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "grain" +version = "0.1.0" +edition = "2021" + +[dependencies] +env_logger = "0.11.5" +log = "0.4.27" +serde = { version = "1.0.210", features = ["derive"] } +axum = { version = "0.8.3", features = ["macros"] } +tokio = { version = "1.40.0", features = ["full"] } +tower-http = { version = "0.5.2", features = ["cors"] } +clap = { version = "4.5.17", features = ["derive", "env"] } +serde_json = "1.0.128" +base64 = "0.22.1" +sha256 = "1.6.0" diff --git a/README.md b/README.md new file mode 100644 index 0000000..85ab866 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# grain +This project aims to be a minimal Docker registry implementation with granular access control per tag. + +## Spec +https://github.com/opencontainers/distribution-spec/blob/v1.0.1/spec.md \ No newline at end of file diff --git a/src/args.rs b/src/args.rs new file mode 100644 index 0000000..cb5ed26 --- /dev/null +++ b/src/args.rs @@ -0,0 +1,13 @@ +use clap::Parser; + +#[derive(Parser, Clone)] +#[command(author, version, about, long_about = None)] +pub(crate) struct Args { + // Web server address + #[arg(long, env, default_value = "0.0.0.0:8888")] + pub(crate) host: String, + + // Path to the users file + #[arg(long, env, default_value = "./tmp/users.json")] + pub(crate) users_file: String, +} diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..40530ff --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,57 @@ +use base64::{prelude::BASE64_STANDARD, Engine}; +use std::sync::Arc; + +use crate::response::{ok, unauthorized}; +use crate::state::{self, User}; +use axum::{ + extract::State, + http::{HeaderMap, Response}, +}; + +fn parse_auth_header(headers: HeaderMap) -> Option { + let auth_header = headers.get("authorization")?; + let auth_str = auth_header.to_str().ok()?; + let auth_decoded_vec = BASE64_STANDARD + .decode(auth_str.trim_start_matches("Basic ")) + .ok()?; + let decoded = String::from_utf8(auth_decoded_vec).ok()?; + + let parts: Vec<&str> = decoded.split(':').collect(); + if parts.len() == 2 { + Some(User { + username: parts[0].to_string(), + password: parts[1].to_string(), + }) + } else { + None + } +} + +pub(crate) async fn get( + State(data): State>, + headers: HeaderMap, +) -> Response { + log::info!("Incoming request headers: {:?}", headers); + + let user = match parse_auth_header(headers) { + Some(user) => { + log::info!("Parsed user from headers: {:?}", user); + user + } + None => { + log::warn!("Failed to parse user from headers"); + return unauthorized(&data.args.host); + } + }; + + let users = data.users.lock().await; + + for u in users.iter() { + if u.username == user.username && u.password == user.password { + log::info!("User authenticated successfully"); + return ok(); + } + } + + return unauthorized(&data.args.host); +} diff --git a/src/blobs.rs b/src/blobs.rs new file mode 100644 index 0000000..b1fdd0c --- /dev/null +++ b/src/blobs.rs @@ -0,0 +1,167 @@ +// | ID | Method | API Endpoint | Success | Failure | +// | ------ | -------------- | ------------------------------------------------------------ | ----------- | ----------------- | +// | end-1 | `GET` | `/v2/` | `200` | `404`/`401` | +// | end-2 | `GET` / `HEAD` | `/v2//blobs/` | `200` | `404` | +// | end-4a | `POST` | `/v2//blobs/uploads/` | `202` | `404` | +// | end-4b | `POST` | `/v2//blobs/uploads/?digest=` | `201`/`202` | `404`/`400` | +// | end-5 | `PATCH` | `/v2//blobs/uploads/` | `202` | `404`/`416` | +// | end-6 | `PUT` | `/v2//blobs/uploads/?digest=` | `201` | `404`/`400` | +// | end-10 | `DELETE` | `/v2//blobs/` | `202` | `404`/`405` | +// | end-11 | `POST` | `/v2//blobs/uploads/?mount=&from=` | `201` | `404` | + +use serde::Deserialize; +use serde_json::{json, Value}; +use std::sync::Arc; + +use crate::{ + response::{not_found, not_implemented}, + state, + storage::write_blob, +}; +use axum::{ + body::Body, + extract::{Path, Query, State}, + http::Request, + response::{Json, Response}, +}; + +// end-2 GET /v2/:name/blobs/:digest +pub(crate) async fn get_blob_by_digest( + Path((org, repo, digest)): Path<(String, String, String)>, +) -> Response { + log::info!( + "blobs/get_blob_by_digest: org: {}, repo {}, digest: {}", + org, + repo, + digest + ); + + return not_implemented(); +} + +// end-2 HEAD /v2/:name/blobs/:digest +pub(crate) async fn head_blob_by_digest( + Path((org, repo, digest)): Path<(String, String, String)>, +) -> Response { + log::info!( + "blobs/head_blob_by_digest: org: {}, repo {}, digest: {}", + org, + repo, + digest + ); + + return not_found(); +} + +// end-4a POST /v2/:name/blobs/uploads/ +// end-4b POST /v2/:name/blobs/uploads/?digest=:digest +// end-11 POST /v2/:name/blobs/uploads/?mount=:digest&from=:other_name +#[derive(Deserialize)] +pub(crate) struct PostBlobUploadQueryParams { + digest: Option, + mount: Option, + from: Option, +} + +pub(crate) async fn post_blob_upload( + Path((org, repo)): Path<(String, String)>, +) -> Response { + log::info!("blobs/post_blob_upload: org: {}, repo: {}", org, repo,); + + return Response::builder() + .status(202) + .header("Location", format!("/v2/{}/{}/blobs/uploads/", org, repo)) + .body("202 Accepted".to_string()) + .unwrap(); +} + +pub(crate) async fn put_blob_upload( + Path((org, repo)): Path<(String, String)>, + query: Query, + body: Request, +) -> Response { + log::info!( + "blobs/put_blob_upload: org: {}, repo: {}, digest: {:#?}, mount: {:#?}, from: {:#?}", + org, + repo, + query.digest, + query.mount, + query.from + ); + + let success = match &query.digest { + Some(digest) => write_blob(&org, &repo, &digest, body.into_body()).await, + None => false, + }; + + if !success { + return Response::builder() + .status(400) + .body("400 Bad Request".to_string()) + .unwrap(); + } + + return Response::builder() + .status(201) + .header("Location", format!("/v2/{}/blobs/uploads/{}", org, repo)) + .header("Docker-Content-Digest", query.digest.as_ref().unwrap()) + .body("201 Created".to_string()) + .unwrap(); +} + +// end-5 PATCH /v2/:name/blobs/uploads/:reference +pub(crate) async fn patch_blob_upload( + State(data): State>, + Path(name): Path, + Path(reference): Path, +) -> Json { + let status = data.server_status.lock().await; + log::info!( + "blobs/patch_blob_upload: name: {}, reference: {}", + name, + reference + ); + return Json(json!({ + "not_implemented": format!("name {} reference {} server_status {}", name, reference, status) + })); +} + +// end-6 PUT /v2/:name/blobs/uploads/:reference?digest=:digest +#[derive(Deserialize)] +pub(crate) struct End6QueryParams { + digest: String, +} +pub(crate) async fn put_blob_upload_by_reference( + State(data): State>, + Path(name): Path, + Path(reference): Path, + query: Query, +) -> Json { + let status = data.server_status.lock().await; + log::info!( + "blobs/put_blob_upload_by_reference: name: {}, reference: {}, digest: {}", + name, + reference, + query.digest + ); + return Json(json!({ + "not_implemented": format!("name {} reference {} digest {} server_status {}", name, reference, query.digest, status) + })); +} + +// end-10 DELETE /v2/:name/blobs/:digest +pub(crate) async fn delete_blob_by_digest( + State(data): State>, + Path(name): Path, + Path(digest): Path, +) -> Json { + let status = data.server_status.lock().await; + log::info!( + "blobs/delete_blob_by_digest: name: {}, digest: {}", + name, + digest + ); + return Json(json!({ + "not_implemented": format!("name {} digest {} server_status {}", name, digest, status) + })); +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..4ac6044 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,91 @@ +use std::sync::Arc; + +use axum::{ + routing::{delete, get, head, patch, post, put}, + Router, +}; +use clap::Parser; +use tower_http::cors::CorsLayer; + +mod args; +mod auth; +mod blobs; +mod manifests; +mod meta; +mod response; +mod state; +mod storage; +mod tags; +mod utils; + +#[tokio::main] +async fn main() { + let args = args::Args::parse(); + env_logger::init(); + log::info!("Starting grain build: {}", utils::get_build_info()); + + // Shared app state + let shared_state = Arc::new(state::new_app(&args)); + + let app = Router::new() + .route("/", get(meta::index)) // Index, info + .route("/v2/", get(auth::get)) // end-1 + .route( + "/v2/{org}/{repo}/manifests/{reference}", + head(manifests::head_manifest_by_reference), + ) + .route( + "/v2/{org}/{repo}/manifests/{reference}", + get(manifests::get_manifest_by_reference), + ) + .route( + "/v2/{org}/{repo}/blobs/{digest}", + get(blobs::get_blob_by_digest), + ) // end-2 + .route( + "/v2/{org}/{repo}/blobs/{digest}", + head(blobs::head_blob_by_digest), + ) + .route( + "/v2/{org}/{repo}/blobs/uploads/", + post(blobs::post_blob_upload), + ) // end-4a, end-4b, end-11 + .route( + "/v2/{org}/{repo}/blobs/uploads/", + put(blobs::put_blob_upload), + ) // returned location for actual file upload + .route( + "/v2/{org}/{repo}/blobs/uploads/{reference}", + patch(blobs::patch_blob_upload), + ) // end-5 + .route( + "/v2/{org}/{repo}/blobs/uploads/{reference}", + put(blobs::put_blob_upload_by_reference), + ) // end-6 + .route( + "/v2/{org}/{repo}/manifests/{reference}", + put(manifests::put_manifest_by_reference), + ) // end-7 + .route("/v2/{org}/{repo}/tags/list", get(tags::get_tags_list)) // end-8a, end-8b + .route( + "/v2/{org}/{repo}/manifests/{reference}", + delete(manifests::delete_manifest_by_reference), + ) // end-9 + .route( + "/v2/{org}/{repo}/blobs/{digest}", + delete(blobs::delete_blob_by_digest), + ) // end-10 + // Catch-all routes for debugging + .route("/{*path}", head(meta::catch_all_head)) + .route("/{*path}", get(meta::catch_all_get)) + .route("/{*path}", post(meta::catch_all_post)) + .route("/{*path}", put(meta::catch_all_put)) + .route("/{*path}", patch(meta::catch_all_patch)) + .route("/{*path}", delete(meta::catch_all_delete)) + .layer(CorsLayer::permissive()) + .with_state(shared_state); + + log::info!("Listening on: {}", &args.host); + let listener = tokio::net::TcpListener::bind(&args.host).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/src/manifests.rs b/src/manifests.rs new file mode 100644 index 0000000..387577d --- /dev/null +++ b/src/manifests.rs @@ -0,0 +1,95 @@ +// | ID | Method | API Endpoint | Success | Failure | +// | ------ | -------------- | ------------------------------------------------------------ | ----------- | ----------------- | +// | end-3 | `GET` / `HEAD` | `/v2//manifests/` | `200` | `404` | +// | end-7 | `PUT` | `/v2//manifests/` | `201` | `404` | +// | end-9 | `DELETE` | `/v2//manifests/` | `202` | `404`/`400`/`405` | + +use serde_json::{json, Value}; +use std::sync::Arc; + +use crate::{ + response::{not_found, not_implemented}, + state, + storage::write_manifest, +}; +use axum::{ + body::Body, + extract::{Path, State}, + http::Request, + response::{Json, Response}, +}; + +// end-3 GET /v2/:name/manifests/:reference +pub(crate) async fn get_manifest_by_reference( + State(data): State>, + Path((org, repo, reference)): Path<(String, String, String)>, +) -> Json { + let status = data.server_status.lock().await; + log::info!( + "manifests/get_manifest_by_reference: org: {}, repo: {}, reference: {}", + org, + repo, + reference + ); + return Json(json!({ + "not_implemented": format!("org {} repo {} reference {} server_status {}", org, repo, reference, status) + })); +} + +// end-3 HEAD /v2/:name/manifests/:reference +pub(crate) async fn head_manifest_by_reference( + Path((org, repo, reference)): Path<(String, String, String)>, +) -> Response { + log::info!( + "manifests/head_manifest_by_reference: org: {}, repo: {}, reference: {}", + org, + repo, + reference + ); + + return not_found(); +} + +// end-7 PUT /v2/:name/manifests/:reference +#[axum::debug_handler] +pub(crate) async fn put_manifest_by_reference( + Path((org, repo, reference)): Path<(String, String, String)>, + body: Request, +) -> Response { + log::info!( + "manifests/put_manifest_by_reference: org: {}, repo: {}, reference: {}", + org, + repo, + reference + ); + + let success = write_manifest(&org, &repo, &reference, body.into_body()).await; + if !success { + return Response::builder() + .status(400) + .body("400 Bad Request".to_string()) + .unwrap(); + } + + return Response::builder() + .status(201) + .header( + "Location", + format!("/v2/{}/{}/manifests/{}", org, repo, reference), + ) + .body("201 Created".to_string()) + .unwrap(); +} + +// end-9 DELETE /v2/:name/manifests/:reference +pub(crate) async fn delete_manifest_by_reference( + Path(name): Path, + Path(reference): Path, +) -> Response { + log::info!( + "manifests/delete_manifest_by_reference: name: {}, reference: {}", + name, + reference + ); + return not_implemented(); +} diff --git a/src/meta.rs b/src/meta.rs new file mode 100644 index 0000000..1de09fa --- /dev/null +++ b/src/meta.rs @@ -0,0 +1,46 @@ +use serde_json::{json, Value}; +use std::sync::Arc; + +use crate::{state, utils}; +use axum::{ + extract::{Path, State}, + response::Json, +}; + +pub(crate) async fn index(State(data): State>) -> Json { + let status = data.server_status.lock().await; + log::info!("meta/index: server_status: {}", status); + return Json(json!({ + "server": format!("grain {} status {}", utils::get_build_info(), status) + })); +} + +pub(crate) async fn catch_all_head(Path(path): Path) -> String { + log::error!("meta/catch_all: HEAD {}", path); + return "Not found".to_string(); +} + +pub(crate) async fn catch_all_get(Path(path): Path) -> String { + log::error!("meta/catch_all: GET {}", path); + return "Not found".to_string(); +} + +pub(crate) async fn catch_all_post(Path(path): Path) -> String { + log::error!("meta/catch_all: POST {}", path); + return "Not found".to_string(); +} + +pub(crate) async fn catch_all_put(Path(path): Path) -> String { + log::error!("meta/catch_all: PUT {}", path); + return "Not found".to_string(); +} + +pub(crate) async fn catch_all_patch(Path(path): Path) -> String { + log::error!("meta/catch_all: PATCH {}", path); + return "Not found".to_string(); +} + +pub(crate) async fn catch_all_delete(Path(path): Path) -> String { + log::error!("meta/catch_all: DELETE {}", path); + return "Not found".to_string(); +} diff --git a/src/response.rs b/src/response.rs new file mode 100644 index 0000000..8ac56b8 --- /dev/null +++ b/src/response.rs @@ -0,0 +1,33 @@ +use axum::http::Response; + +pub(crate) fn not_found() -> Response { + Response::builder() + .status(404) + .body("404 Not Found".to_string()) + .unwrap() +} + +pub(crate) fn not_implemented() -> Response { + Response::builder() + .status(501) + .body("501 Not Implemented".to_string()) + .unwrap() +} + +pub(crate) fn unauthorized(host: &str) -> Response { + Response::builder() + .status(401) + .header( + "WWW-Authenticate", + format!("Basic realm=\"{}\", charset=\"UTF-8\"", host), + ) + .body("401 Unauthorized".to_string()) + .unwrap() +} + +pub(crate) fn ok() -> Response { + Response::builder() + .status(200) + .body("200 OK".to_string()) + .unwrap() +} \ No newline at end of file diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..6ed5331 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,66 @@ +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; + +use std::{collections::HashSet, fmt, fs}; + +use crate::args::Args; + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub(crate) enum ServerStatus { + Starting, + Ready, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Hash)] +pub(crate) struct User { + pub(crate) username: String, + pub(crate) password: String, +} + +impl fmt::Display for ServerStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ServerStatus::Starting => write!(f, "Starting"), + ServerStatus::Ready => write!(f, "Ready"), + } + } +} + +pub(crate) struct App { + pub(crate) server_status: Mutex, + pub(crate) users: Mutex>, + pub(crate) args: Args, +} + +fn load_users_from_file(file_path: &str) -> HashSet { + let file_content = match fs::read_to_string(file_path) { + Ok(content) => content, + Err(err) => { + log::error!("Failed to read users file {}: {}", file_path, err); + return HashSet::new(); + } + }; + + let users: Vec = match serde_json::from_str(&file_content) { + Ok(users) => users, + Err(err) => { + log::error!( + "Failed to parse JSON from users file {}: {}", + file_path, + err + ); + return HashSet::new(); + } + }; + + log::info!("Loaded {} users", users.len()); + HashSet::from_iter(users) +} + +pub(crate) fn new_app(args: &Args) -> App { + return App { + server_status: Mutex::new(ServerStatus::Starting), + users: Mutex::new(load_users_from_file(&args.users_file)), + args: args.clone(), + }; +} diff --git a/src/storage.rs b/src/storage.rs new file mode 100644 index 0000000..a329faf --- /dev/null +++ b/src/storage.rs @@ -0,0 +1,106 @@ +use axum::body::Body; +use std::{ + fs::{create_dir_all, File}, + io::Write, +}; + +fn sanitize_string(input: &str) -> String { + input + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' || c == '/' { + c + } else { + '_' + } + }) + .collect() +} + +pub(crate) async fn write_blob( + org: &String, + repo: &String, + req_digest_string: &String, + body: Body, +) -> bool { + let bytes_res = axum::body::to_bytes(body, usize::MAX).await; + if bytes_res.is_err() { + return false; + } + let bytes = bytes_res.unwrap(); + + let req_digest = req_digest_string + .strip_prefix("sha256:") + .unwrap_or(req_digest_string); + let body_digest = sha256::digest(bytes.as_ref()); + let matches = req_digest == &body_digest; + + log::info!( + "storage/write_file: digest: {}, body_digest: {}, matches: {}", + req_digest, + body_digest, + matches + ); + + if !matches { + return false; + } + + let base_path = format!( + "./tmp/blobs/{}/{}", + sanitize_string(org), + sanitize_string(repo), + ); + + return write_bytes_to_file(&base_path, &req_digest, &bytes).await; +} + +pub(crate) async fn write_manifest( + org: &String, + repo: &String, + reference: &String, + body: Body, +) -> bool { + let bytes_res = axum::body::to_bytes(body, usize::MAX).await; + if bytes_res.is_err() { + return false; + } + let bytes = bytes_res.unwrap(); + + let base_path = format!( + "./tmp/manifests/{}/{}", + sanitize_string(org), + sanitize_string(repo), + ); + + return write_bytes_to_file(&base_path, &reference, &bytes).await; +} + +pub(crate) async fn write_bytes_to_file(base_path: &str, file_name: &str, bytes: &[u8]) -> bool { + if let Err(e) = create_dir_all(&base_path) { + log::error!("storage/write_file: error creating directory: {}", e); + return false; + } + + let mut file = match File::create(format!("{}/{}", base_path, file_name)) { + Ok(file) => file, + Err(e) => { + log::error!("storage/write_file: error creating file: {}", e); + return false; + } + }; + + if let Err(e) = file.write_all(&bytes) { + log::error!("storage/write_file: error writing to file: {}", e); + return false; + } + + if let Err(e) = file.flush() { + log::error!("storage/write_file: error flushing file: {}", e); + return false; + } + + log::info!("storage/write_file: wrote to {}", base_path); + + return true; +} diff --git a/src/tags.rs b/src/tags.rs new file mode 100644 index 0000000..9fddb01 --- /dev/null +++ b/src/tags.rs @@ -0,0 +1,38 @@ +// | ID | Method | API Endpoint | Success | Failure | +// | ------ | -------------- | ------------------------------------------------------------ | ----------- | ----------------- | +// | end-8a | `GET` | `/v2//tags/list` | `200` | `404` | +// | end-8b | `GET` | `/v2//tags/list?n=&last=` | `200` | `404` | + +use serde::Deserialize; +use serde_json::{json, Value}; +use std::sync::Arc; + +use crate::state; +use axum::{ + extract::{Path, Query, State}, + response::Json, +}; + +// end-8a GET /v2/:name/tags/list +// end-8b GET /v2/:name/tags/list?n=&last= +#[derive(Deserialize)] +pub(crate) struct End8bQueryParams { + n: String, + last: String, +} +pub(crate) async fn get_tags_list( + State(data): State>, + Path(name): Path, + query: Query, +) -> Json { + let status = data.server_status.lock().await; + log::info!( + "tags/get_tags_list: name: {}, n: {}, last: {}", + name, + query.n, + query.last + ); + return Json(json!({ + "not_implemented": format!("name {} n {:?} last {:?} server_status {}", name, query.n, query.last, status) + })); +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..d9e5c10 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,12 @@ +pub(crate) fn get_build_info() -> String { + let raw_ver = option_env!("BUILD_VERSION"); + if raw_ver.is_none() { + return "test".to_string(); + } + let raw_ver = raw_ver.unwrap(); + + let long_sha = raw_ver.split("-").last().unwrap(); + let short_sha = long_sha.chars().take(7).collect::(); + + raw_ver.replace(long_sha, &short_sha) +} \ No newline at end of file