From c9f377872a528c631690147a60424444aab41475 Mon Sep 17 00:00:00 2001 From: William Villeneuve Date: Sat, 26 Oct 2024 09:53:58 -0400 Subject: [PATCH] debug --- .cargo/config.toml | 10 +- .github/workflows/release.yml | 49 +-- Cargo.lock | 17 + Cargo.toml | 2 +- meepis/Cargo.toml | 24 ++ meepis/plugin.yml | 45 +++ meepis/src/args.rs | 35 ++ meepis/src/main.rs | 604 ++++++++++++++++++++++++++++++++++ 8 files changed, 764 insertions(+), 22 deletions(-) create mode 100644 meepis/Cargo.toml create mode 100644 meepis/plugin.yml create mode 100644 meepis/src/args.rs create mode 100644 meepis/src/main.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 35c67ad..ed629ee 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,10 @@ -[target.'cfg(all(windows, target_env = "msvc"))'] +[target.'cfg(target_env = "msvc")'] rustflags = ["-C", "target-feature=+crt-static"] + +[target.'cfg(target_env = "musl")'] +rustflags = [ + "-C", + "target-feature=+crt-static", + "-L", + "-Wl,--copy-dt-needed-entries", +] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c9f26b1..3791bdc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,9 @@ name: release on: push: - branches: - - main + # TODO: + # branches: + # - main jobs: build: name: Build @@ -11,25 +12,21 @@ jobs: build: - target: x86_64-apple-darwin os: macos-latest - - target: aarch64-apple-darwin - os: macos-latest - - target: x86_64-unknown-linux-musl - os: ubuntu-latest - - target: x86_64-pc-windows-msvc - os: windows-latest + # TODO: + # - target: aarch64-apple-darwin + # os: macos-latest + # - target: x86_64-unknown-linux-musl + # os: ubuntu-latest + # - target: x86_64-pc-windows-msvc + # os: windows-latest runs-on: ${{ matrix.build.os }} steps: - uses: actions/checkout@v4 + - name: Install target + run: rustup target add ${{ matrix.build.target }} - name: Setup musl - run: | - rustup target add x86_64-unknown-linux-musl - sudo apt-get install -y musl-tools - echo 'LDFLAGS=-Wl,--copy-dt-needed-entries' >> $GITHUB_ENV - echo 'RUSTFLAGS=-C target-feature=+crt-static' >> $GITHUB_ENV - if: matrix.build.target == 'x86_64-unknown-linux-musl' - - name: Setup Apple Silicon - run: rustup target add aarch64-apple-darwin - if: matrix.build.target == 'aarch64-apple-darwin' + run: sudo apt-get install -y musl-tools + if: endsWith(matrix.build.target, '-musl') - uses: Swatinem/rust-cache@v2 - name: Build env: @@ -45,12 +42,15 @@ jobs: --message-format=json \ | jq -r '.message.rendered // .executable // empty' )" - echo "ARTIFACT_PATH=$output" >> $GITHUB_ENV + # TODO: might need newlines? just multiline this guy probs + echo "ARTIFACT_PATHS=$output" >> $GITHUB_ENV + - run: echo "$ARTIFACT_PATHS" - name: Upload binary uses: actions/upload-artifact@v4 with: name: ${{ matrix.build.target }} - path: ${{ env.ARTIFACT_PATH }} + path: ${{ env.ARTIFACT_PATHS }} + if-no-files-found: error release: name: Release needs: build @@ -81,6 +81,8 @@ jobs: path: artifacts - name: Move executables run: | + # TODO: no echo + echo artifacts/*/* for file in artifacts/*/*; do exe="$(basename "$file")" plugin="${exe%\.exe}" @@ -95,6 +97,12 @@ jobs: env: REPOSITORY_NAME: ${{ github.event.repository.name }} run: | + # TODO: no + echo nk plugin pack \ + --owner "$GITHUB_REPOSITORY_OWNER" \ + --repo "$REPOSITORY_NAME" \ + --version "$TAG" \ + ./*/plugin.yml nk plugin pack \ --owner "$GITHUB_REPOSITORY_OWNER" \ --repo "$REPOSITORY_NAME" \ @@ -105,7 +113,8 @@ jobs: GITHUB_USER: ${{ github.repository_owner }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh release create \ + # TODO: no echo + echo gh release create \ --title "$TAG" \ --notes '' \ "$TAG" \ diff --git a/Cargo.lock b/Cargo.lock index 5873e63..5c387c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -210,6 +210,23 @@ version = "0.2.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a08173bc88b7955d1b3145aa561539096c421ac8debde8cbc3612ec635fee29b" +[[package]] +name = "meepis" +version = "0.1.0" +dependencies = [ + "anyhow", + "camino", + "clap", + "dirs", + "faccess", + "file-id", + "serde", + "serde_json", + "shellexpand", + "walkdir", + "windows", +] + [[package]] name = "option-ext" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 203c088..9033842 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] resolver = "2" -members = ["files"] +members = ["files", "meepis"] diff --git a/meepis/Cargo.toml b/meepis/Cargo.toml new file mode 100644 index 0000000..0bb39d7 --- /dev/null +++ b/meepis/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "meepis" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.51.1", features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", +] } + +[dependencies] +anyhow = "1.0.75" +camino = { version = "1.1.6", features = ["serde1"] } +clap = { version = "4.4.6", features = ["derive"] } +dirs = "5.0.1" +faccess = "0.2.4" +file-id = "0.2.1" +serde = { version = "1.0.189", features = ["derive"] } +serde_json = "1.0.107" +shellexpand = "3.1.0" +walkdir = "2.4.0" diff --git a/meepis/plugin.yml b/meepis/plugin.yml new file mode 100644 index 0000000..fde9392 --- /dev/null +++ b/meepis/plugin.yml @@ -0,0 +1,45 @@ +name: files + +provision: + when: declaration in ["files", "directories"] + +schema: + $schema: https://json-schema.org/draft/2020-12/schema + # TODO: fix this, schemas need to be associated with their relevant declarations... + anyOf: + - type: string + - type: object + properties: + source: + type: string + destination: + type: string + link_files: + type: boolean + required: + - source + - destination + +--- +when: + - os == "macos" + - arch == "x86_64" +executable: assets/x86_64-apple-darwin/files + +--- +when: + - os == "macos" + - arch == "aarch64" +executable: assets/aarch64-apple-darwin/files + +--- +when: + - os == "linux" + - arch == "x86_64" +executable: assets/x86_64-unknown-linux-musl/files + +--- +when: + - family == "windows" + - arch == "x86_64" +executable: assets/x86_64-pc-windows-msvc/files.exe diff --git a/meepis/src/args.rs b/meepis/src/args.rs new file mode 100644 index 0000000..3855acf --- /dev/null +++ b/meepis/src/args.rs @@ -0,0 +1,35 @@ +use anyhow::Error; +use camino::Utf8PathBuf; +use clap::{arg, Args, Parser, Subcommand}; +use serde::Deserialize; + +#[derive(Debug, Parser)] +#[command(about, version)] +pub struct Arguments { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Debug, Subcommand)] +pub enum Commands { + Provision(Provision), +} + +#[derive(Debug, Args)] +pub struct Provision { + /// Provision info as json + #[arg(value_name = "info", value_parser = ProvisionInfo::value_parser)] + pub info: ProvisionInfo, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct ProvisionInfo { + pub sources: Vec, + // pub vars: serde_json::Map, +} + +impl ProvisionInfo { + fn value_parser(value: &str) -> Result { + Ok(serde_json::from_str(value)?) + } +} diff --git a/meepis/src/main.rs b/meepis/src/main.rs new file mode 100644 index 0000000..9b716f4 --- /dev/null +++ b/meepis/src/main.rs @@ -0,0 +1,604 @@ +#![warn(clippy::all, clippy::nursery, clippy::cargo, clippy::single_match_else)] +#![allow(clippy::cargo_common_metadata)] + +mod args; + +use anyhow::{anyhow, Result}; +use args::{Arguments, Commands, Provision}; +use camino::{Utf8Path, Utf8PathBuf}; +use clap::Parser; +use faccess::PathExt; +use file_id::get_file_id; +use serde::{de::Error, Deserialize, Deserializer, Serialize}; +use std::{ + fs::{copy, create_dir_all, remove_dir_all, remove_file, File}, + io::{stdin, Read}, + path::Path, + str::FromStr, +}; +use walkdir::WalkDir; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case", tag = "declaration", content = "state")] +enum State { + Files(FileState), + Directories(#[serde(deserialize_with = "expand_path")] Utf8PathBuf), +} + +#[derive(Debug, Deserialize)] +struct FileState { + source: Utf8PathBuf, + #[serde(deserialize_with = "expand_path")] + destination: Utf8PathBuf, + #[serde(default)] + link_files: bool, +} + +fn expand_path<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let path: String = Deserialize::deserialize(deserializer)?; + // TODO: maybe std::path::absolute once stable? + Utf8PathBuf::from_str(&shellexpand::tilde(&path)).map_err(D::Error::custom) +} + +fn main() { + let args = Arguments::parse(); + + match args.command { + Commands::Provision(args) => provision(args), + } +} + +fn display_path_with_tilde(path: &Utf8Path) -> String { + let mut path_string = path.to_string(); + + if let Some(home_dir) = dirs::home_dir() { + if let Ok(home) = Utf8PathBuf::try_from(home_dir) { + let home_str = home.as_str(); + if path_string.starts_with(home_str) { + path_string.replace_range(0..home_str.len(), "~"); + } + } + } + + path_string +} + +fn print_result(result: &NkProvisionStateResult) { + let json = serde_json::to_string(result) + .expect("state results to not throw errors serializing..."); + + println!("{json}"); +} + +fn provision(args: Provision) { + let nk_sources = args.info.sources; + + let states: Vec = match serde_json::from_reader(stdin()) { + Ok(v) => v, + Err(e) => { + // fallback error handler for the deserialize + print_result(&NkProvisionStateResult { + status: NkProvisionStateStatus::Failed, + changed: false, + description: "files".into(), + output: format!("{e}: failed deserializing"), + }); + + return; + } + }; + + for state in states { + match state { + State::Files(state) => { + if let Err(result) = provision_file(&nk_sources, &state) { + // fallback error handler for the provision + print_result(&NkProvisionStateResult { + status: NkProvisionStateStatus::Failed, + changed: false, + description: display_path_with_tilde( + &state.destination, + ), + output: result.to_string(), + }); + } + } + State::Directories(destination) => { + provision_directory(&destination); + } + }; + } +} + +#[derive(Debug, Serialize, Clone)] +struct NkProvisionStateResult { + status: NkProvisionStateStatus, + changed: bool, + description: String, + output: String, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "snake_case")] +enum NkProvisionStateStatus { + Failed, + Success, +} + +impl NkProvisionStateResult { + fn append_change(&mut self, change: Result) -> Result { + match change { + Ok(v) => { + self.changed = true; + + Ok(v) + } + Err(e) => { + self.status = NkProvisionStateStatus::Failed; + self.output.push_str(&e); + self.output.push('\n'); + + Err(()) + } + } + } + + fn append_check(&mut self, check: Result) -> Result { + match check { + Ok(v) => Ok(v), + Err(e) => { + self.status = NkProvisionStateStatus::Failed; + self.output.push_str(&e); + self.output.push('\n'); + + Err(()) + } + } + } +} + +fn provision_file(nk_sources: &[Utf8PathBuf], state: &FileState) -> Result<()> { + let FileState { + source, + destination, + link_files, + } = state; + // find sources + let nk_source_relative_sources = nk_sources + .iter() + .map(|nk_source| nk_source.join(source)) + .filter(|p| p.exists()) + .collect::>(); + + // need at least one source to proceed + if nk_source_relative_sources.is_empty() { + return Err(anyhow!("{source}: does not exist")); + } + + // check if any sources aren't listable + let nk_source_relative_sources = nk_source_relative_sources + .iter() + .map(|p| { + if p.is_dir() && !p.as_std_path().executable() { + return Err(anyhow!("{p}: is not listable")); + } + + Ok(p) + }) + .collect::>>()?; + + // walk each source + for nk_source_relative_source in nk_source_relative_sources { + for entry in WalkDir::new(nk_source_relative_source).sort_by_file_name() + { + let entry = entry?; + let source_file = + Utf8PathBuf::from_path_buf(entry.path().into()).unwrap(); + + // figure out destination file path + let destination_file = if source_file == *nk_source_relative_source + { + // root of the source + destination.clone() + } else { + // child of the source + destination + .join(source_file.strip_prefix(nk_source_relative_source)?) + }; + + let action = if !link_files || source_file.is_dir() { + "create" + } else { + "link" + }; + + let mut result = NkProvisionStateResult { + status: NkProvisionStateStatus::Success, + changed: false, + description: format!( + "{action} {}", + display_path_with_tilde(&destination_file) + ), + output: String::new(), + }; + + // NOTE: result is exclusively used to make it's implementation + // cleaner (so we can exit if any change fails), all success/failure + // details are returned through the mutable result + let _ = provision_sub_file( + &mut result, + &source_file, + &destination_file, + *link_files, + ); + + print_result(&result); + } + } + + Ok(()) +} + +fn provision_sub_file( + result: &mut NkProvisionStateResult, + source_file: &Utf8Path, + destination_file: &Utf8Path, + link_files: bool, +) -> Result<(), ()> { + // create parent directory + if let Some(destination_parent) = destination_file.parent() { + if !destination_parent.exists() { + // create directory + result.append_change( + create_dir_all(destination_parent).map_err(|e| { + format!("{e}: failed creating parent directory: {destination_parent}") + }), + )?; + } + + // TODO: should support files.settings or something that we can configure a umask with, then configure that first (assuming it'll apply immediately, if not, use it to calculate perms) + #[cfg(unix)] + { + use std::fs::set_permissions; + use std::os::unix::prelude::MetadataExt; + use std::os::unix::prelude::PermissionsExt; + + let metadata = + destination_parent.metadata().expect("accessing metadata"); + let mut permissions = metadata.permissions(); + let existing_mode = permissions.mode() & 0o777; + + // chmod parent directory + // TODO: uid != 0 is to ensure we don't try to chmod /Users or other system folders... (might be a better way of handling this...) + if existing_mode != 0o700 && metadata.uid() != 0 { + permissions.set_mode(0o700); + result.append_change( + set_permissions(destination_parent, permissions).map_err( + |e| { + format!( + "{e}: failed changing permissions of parent: {destination_parent}", + ) + }, + ), + )?; + } + } + + #[cfg(windows)] + { + use std::os::windows::prelude::*; + use windows::Win32::Storage::FileSystem::{ + SetFileAttributesW, FILE_ATTRIBUTE_HIDDEN, + }; + + // hide dotfiles on windows + let file_name = destination_parent.file_name().unwrap_or_default(); + if file_name.starts_with('.') { + let metadata = + destination_parent.metadata().expect("accessing metadata"); + let attributes = metadata.file_attributes(); + + // if not hidden + if (attributes & FILE_ATTRIBUTE_HIDDEN.0) == 0 { + // hide + result.append_change( + unsafe { + SetFileAttributesW( + &destination_parent.as_os_str().into(), + FILE_ATTRIBUTE_HIDDEN, + ) + } + .map_err(|e| { + format!( + "{e}: failed changing attributes of parent: {destination_parent}", + ) + }), + )?; + } + } + } + } + + // create/link + + if source_file.is_dir() { + // create directory + provision_directory_impl(result, destination_file)?; + } else if link_files { + // link file + + let is_linked_to = result.append_check( + is_linked_to(destination_file, source_file).map_err(|e| { + format!("{e}: failed checking link: {destination_file}") + }), + )?; + + if !is_linked_to { + // delete existing first + if destination_file.is_dir() { + result.append_change(remove_dir_all(destination_file).map_err(|e| format!( + "{e}: failed deleting existing directory: {destination_file}", + )))?; + } else if destination_file.is_symlink() || destination_file.exists() + { + result.append_change(remove_file(destination_file).map_err( + |e| { + format!( + "{e}: failed deleting existing file: {destination_file}", + ) + }, + ))?; + } + + // link file + result.append_change( + symlink_file(source_file, destination_file).map_err(|e| { + format!("{e}: failed linking file: {destination_file}") + }), + )?; + } + } else { + // create file + let file_matches = result.append_check( + file_contents_match(source_file, destination_file).map_err(|e| { + format!("{e}: failed linking file: {destination_file}") + }), + )?; + + if !file_matches { + // delete existing first + if destination_file.is_dir() { + result.append_change( + remove_dir_all(destination_file).map_err(|e| { + format!( + "{e}: failed deleting existing directory: {destination_file}", + ) + }), + )?; + } else if destination_file.is_symlink() { + result.append_change(remove_file(destination_file).map_err( + |e| { + format!( + "{e}: failed deleting existing symlink: {destination_file}", + ) + }, + ))?; + } + + // copy file + result.append_change( + copy(source_file, destination_file).map_err(|e| { + format!("{e}: failed copying file: {destination_file}") + }), + )?; + } + + // TODO: should support files.settings or something that we can configure a umask with, then configure that first (assuming it'll apply immediately, if not, use it to calculate perms) + #[cfg(unix)] + { + use std::fs::set_permissions; + use std::os::unix::prelude::PermissionsExt; + + let metadata = + destination_file.metadata().expect("accessing metadata"); + let mut permissions = metadata.permissions(); + let existing_mode = permissions.mode() & 0o777; + + // determine perms to set + let perms = if source_file.as_std_path().executable() { + 0o700 + } else { + 0o600 + }; + + // chmod file + if existing_mode != perms { + permissions.set_mode(perms); + result.append_change( + set_permissions(destination_file, permissions).map_err( + |e| { + format!( + "{e}: failed changing permissions of file: {destination_file}", + ) + }, + ), + )?; + } + } + } + + #[cfg(windows)] + { + use std::os::windows::prelude::*; + use windows::Win32::Storage::FileSystem::{ + SetFileAttributesW, FILE_ATTRIBUTE_HIDDEN, + }; + + // hide dotfiles on windows + let file_name = destination_file.file_name().unwrap_or_default(); + if file_name.starts_with('.') { + let metadata = + destination_file.metadata().expect("accessing metadata"); + let attributes = metadata.file_attributes(); + + // if not hidden + if (attributes & FILE_ATTRIBUTE_HIDDEN.0) == 0 { + // hide + result.append_change( + unsafe { + SetFileAttributesW( + &destination_file.as_os_str().into(), + FILE_ATTRIBUTE_HIDDEN, + ) + } + .map_err(|e| { + format!( + "{e}: failed changing attributes of directory: {destination_file}", + ) + }), + )?; + } + } + } + + Ok(()) +} + +fn provision_directory(destination: &Utf8Path) { + let mut result = NkProvisionStateResult { + status: NkProvisionStateStatus::Success, + changed: false, + description: format!("create {}", display_path_with_tilde(destination)), + output: String::new(), + }; + + // NOTE: result is exclusively used to make it's implementation + // cleaner (so we can exit if any change fails), all success/failure + // details are returned through the mutable result + let _ = provision_directory_impl(&mut result, destination); + + print_result(&result); +} + +// TODO: rename... +fn provision_directory_impl( + result: &mut NkProvisionStateResult, + destination: &Utf8Path, +) -> Result<(), ()> { + if !destination.is_dir() { + // delete existing first + if destination.exists() { + result.append_change(remove_file(destination).map_err(|e| { + format!("{e}: failed deleting existing file: {destination}") + }))?; + } + + // create directory + result.append_change(create_dir_all(destination).map_err(|e| { + format!("{e}: failed creating directory: {destination}") + }))?; + } + + // TODO: should support files.settings or something that we can configure a umask with, then configure that first (assuming it'll apply immediately, if not, use it to calculate perms) + #[cfg(unix)] + { + use std::fs::set_permissions; + use std::os::unix::prelude::PermissionsExt; + + let metadata = destination.metadata().expect("accessing metadata"); + let mut permissions = metadata.permissions(); + let existing_mode = permissions.mode() & 0o777; + + // chmod directory + if existing_mode != 0o700 { + permissions.set_mode(0o700); + result.append_change( + set_permissions(destination, permissions).map_err( + |e| { + format!( + "{e}: failed changing permissions of directory: {destination}", + ) + }, + ), + )?; + } + } + + Ok(()) +} + +fn is_linked_to( + destination_file: &Utf8Path, + source_file: &Utf8Path, +) -> std::io::Result { + // if destination doesn't exist (ie. broken link), it's not linked + if !destination_file.exists() { + return Ok(false); + } + + let destination_file_id = get_file_id(destination_file)?; + let source_file_id = get_file_id(source_file)?; + + Ok(destination_file_id == source_file_id) +} + +#[cfg(unix)] +fn symlink_file, Q: AsRef>( + original: P, + link: Q, +) -> std::io::Result<()> { + std::os::unix::fs::symlink(original, link) +} + +#[cfg(windows)] +fn symlink_file, Q: AsRef>( + original: P, + link: Q, +) -> std::io::Result<()> { + std::os::windows::fs::symlink_file(original, link) +} + +fn file_contents_match( + source: &Utf8Path, + destination: &Utf8Path, +) -> std::io::Result { + if !destination.exists() || destination.is_dir() { + return Ok(false); + } + + let mut source_file = File::open(source)?; + let mut destination_file = File::open(destination)?; + + // check file size + if source_file.metadata()?.len() != destination_file.metadata()?.len() { + return Ok(false); + } + + // check file contents + let mut source_contents = String::new(); + let mut destination_contents = String::new(); + + source_file.read_to_string(&mut source_contents)?; + destination_file.read_to_string(&mut destination_contents)?; + + if source_contents != destination_contents { + return Ok(false); + } + + // TODO: allow this to handle large files (maybe have a size limit where it still does the simple thing of loading the whole thing? since that's likely fairly fast...) + // // check file contents + // let mut source_buf_reader = BufReader::new(source_file); + // let mut destination_buf_reader = BufReader::new(destination_file); + + // const SIZE: usize = 8192; + // let mut source_buffer = [0u8; SIZE]; + // let mut destination_buffer = [0u8; SIZE]; + + // source_buf_reader.read_exact(&mut source_buffer)?; + // destination_buf_reader.read_exact(&mut destination_buffer)?; + + Ok(true) +}