Compare commits

...

67 Commits

Author SHA1 Message Date
Andrew Phillips
d3e0b86a91 fix: add missing common module declaration in main.rs
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-11 14:23:31 -03:00
Andrew Phillips
2098d163e5 fix: resolve import errors and add missing Write trait
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-11 14:22:58 -03:00
Andrew Phillips
55d97a7ea1 refactor: remove duplicate binary detection logic from BinaryMetaPlugin
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-11 14:20:50 -03:00
Andrew Phillips
86dabbdbc0 refactor: move binary detection to common module and enhance get logic
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-11 14:18:53 -03:00
Andrew Phillips
6f27530b3b fix: adjust printable character ratio threshold for binary detection
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-11 13:22:04 -03:00
Andrew Phillips
ab48d19dcd fix: adjust binary detection threshold to properly classify random data as binary
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-11 13:21:58 -03:00
Andrew Phillips
ab7dc4c34f fix: correct metadata access in get mode to use proper DB function
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-11 13:16:45 -03:00
Andrew Phillips
40f07b6915 feat: add --force option to allow binary output to TTY
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-11 13:13:53 -03:00
Andrew Phillips
50de138e23 feat: add --force option to override binary data TTY output prevention
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-11 13:13:27 -03:00
Andrew Phillips
f8c896fa25 fix: make generate_status_info public and remove unused imports
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-11 13:04:19 -03:00
Andrew Phillips
ff588b9db2 refactor: decompose server module into endpoint-specific files
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-11 13:01:57 -03:00
Andrew Phillips
da0a771683 feat: add common server mode utilities 2025-08-11 13:01:53 -03:00
Andrew Phillips
d5fb446763 refactor: unify status data generation and output formatting
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-11 12:22:54 -03:00
Andrew Phillips
c4e037d9c6 feat: sort compression types in status display
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-11 12:19:57 -03:00
Andrew Phillips
e2bcdd2acf refactor: use is_internal function to determine plugin type instead of hardcoded list
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-11 12:15:51 -03:00
Andrew Phillips
f5149cfb68 feat: sort meta plugins list by name in status output
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-11 12:14:06 -03:00
Andrew Phillips
e8eaf7aeb1 feat: Add is_internal method to MetaPlugin trait and implementations
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-11 12:11:00 -03:00
Andrew Phillips
6588c827be feat: add is_internal trait method to distinguish internal plugins
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-11 11:52:52 -03:00
Andrew Phillips
f58336e67c fix: add missing binary meta plugin and update plugin list sorting
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-11 11:42:23 -03:00
Andrew Phillips
b97e79ed2f refactor: improve binary detection with expanded file type support and cleaner code structure
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-11 11:38:12 -03:00
Andrew Phillips
68d182ee0b feat: expand binary detection to include common Linux file types
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-11 11:34:52 -03:00
Andrew Phillips
dc550c3f35 feat: add binary meta plugin to detect text vs binary content
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-11 11:32:16 -03:00
Andrew Phillips
ac531354d5 fix: prevent duplicate database connection in server mode
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-11 10:44:39 -03:00
Andrew Phillips
29aa477417 fix: add Clone derive to nested argument structs to satisfy trait bound
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-10 23:03:05 -03:00
Andrew Phillips
37654eb911 fix: add Clone derive to Args struct to fix compilation error
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-10 23:02:51 -03:00
Andrew Phillips
96aef4f02c fix: update args initialization in server state
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-10 23:02:15 -03:00
Andrew Phillips
118e02c56e fix: use actual server args in status handler instead of dummy args
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-10 23:02:03 -03:00
Andrew Phillips
e292bfa886 refactor: update dummy args structure for status mode compatibility
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-10 23:01:23 -03:00
Andrew Phillips
f7ee3a0796 fix: resolve compilation errors in server mode status command
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-10 23:01:13 -03:00
Andrew Phillips
bb28b4f41a fix: make status info structs public and add missing imports
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-10 23:00:24 -03:00
Andrew Phillips
7ed3291e97 feat: Update /status route to match status mode JSON output
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-10 22:59:03 -03:00
Andrew Phillips
ed8bf0f7fc refactor: remove unused OpenAPI and Swagger UI endpoints and logging statements 2025-08-10 22:59:01 -03:00
Andrew Phillips
0a06098796 fix: resolve middleware compilation errors and clean up unused imports
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-10 22:27:24 -03:00
Andrew Phillips
e9f97b1ffd feat: add middleware for logging requests and responses
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-10 22:26:25 -03:00
Andrew Phillips
00b34cb3f7 refactor: adjust log levels and prefixes for server operations 2025-08-10 22:26:23 -03:00
Andrew Phillips
35ae5776c0 feat: implement content retrieval for REST API endpoints
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-10 21:46:27 -03:00
Andrew Phillips
08f3c3e9e5 fix: remove duplicate and unused imports in server.rs
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-10 21:40:20 -03:00
Andrew Phillips
7b9d127ff0 fix: correct server setup with proper connect info handling
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-10 21:39:23 -03:00
Andrew Phillips
b5b101ee35 fix: add ConnectInfo extension and error logging to server handlers
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-10 21:39:16 -03:00
Andrew Phillips
91af08b48c fix: enable trace feature in tower-http and derive Debug for TagsQuery
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-10 21:36:26 -03:00
Andrew Phillips
badcd66217 feat: add request logging and source address tracking to server
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-10 21:35:50 -03:00
Andrew Phillips
4be4831334 fix: remove unused imports and fix undefined behavior from mem::zeroed
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-10 21:30:16 -03:00
Andrew Phillips
64789ef48b feat: add REST HTTP server mode with OpenAPI documentation and Swagger UI
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-10 21:27:13 -03:00
Andrew Phillips
741d0f19cc build: add serde dependencies for serialization support 2025-08-10 21:27:08 -03:00
Andrew Phillips
380eb59094 feat: add REST HTTP server mode with OpenAPI documentation
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-10 20:42:34 -03:00
Andrew Phillips
e6dad42c6e feat: add validation for --human-readable flag usage with --list and --info modes only
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-10 11:51:51 -03:00
Andrew Phillips
7c1c5bd9c9 fix: resolve compilation errors by adding missing ErrorKind import and removing unused imports
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-10 11:34:44 -03:00
Andrew Phillips
0d1ae9ff12 feat: add --output-format option for json/yaml support in info/status/list modes
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-10 11:21:04 -03:00
Andrew Phillips
9f93d6965f feat: change size formatting to k8s style (e.g. 3Gi, 4Ti)
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-10 11:13:10 -03:00
Andrew Phillips
e390139425 refactor: Remove transaction from save to allow partial saves on failure
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-10 00:34:55 -03:00
Andrew Phillips
a5bc9373a9 fix: remove unused imports in common.rs to eliminate build warnings
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-10 00:27:44 -03:00
Andrew Phillips
0f06d31423 fix: remove unused imports, unnecessary mutable variables, and dead code
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-10 00:27:02 -03:00
Andrew Phillips
7210aa08d0 fix: correct variable name and mutable transaction handling in save and update modes
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-10 00:26:18 -03:00
Andrew Phillips
d4370563c3 fix: correct mutable reference handling and remove unused variables
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-10 00:25:22 -03:00
Andrew Phillips
0e68e5ff03 fix: resolve mutable borrowing issues with Transaction and clean up warnings
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-10 00:24:42 -03:00
Andrew Phillips
469e3640b8 fix: resolve compilation errors by fixing mutable references and removing unused imports
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-10 00:23:58 -03:00
Andrew Phillips
cacf843da7 fix: resolve missing imports and incorrect mutable references in save and update modes
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-10 00:23:34 -03:00
Andrew Phillips
38f2caaf1b fix: resolve compilation errors by adding missing imports and fixing type mismatches
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-10 00:22:12 -03:00
Andrew Phillips
e1c0c81445 fix: resolve compilation errors by adding missing imports and fixing Result types
- Import `anyhow`, `clap::Command`, `log::debug`, and I/O traits
- Fix all `Result` return types to include error type `anyhow::Error`
- Replace `anyhow::anyhow!` with `anyhow!` macro calls
- Fix transaction handling in `mode_save`
- Add missing trait imports for I/O operations and string parsing

Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-10 00:19:00 -03:00
Andrew Phillips
58f047ba6d fix: improve error messages and refactor large functions in save/diff modes
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-10 00:00:33 -03:00
Andrew Phillips
498f3e0b9d chore: mark completed items in PLAN.md as DONE
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-09 23:56:12 -03:00
Andrew Phillips
cb408bafa1 fix: use database transactions for atomic operations in save and update modes
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-09 23:33:53 -03:00
Andrew Phillips
a3eb9e7056 fix: address critical memory safety, error handling, concurrency and security issues
This commit fixes several critical issues across the codebase:
1. Memory safety & resource leaks: Added proper cleanup for compression engine processes using RAII patterns
2. Error handling: Replaced unsafe unwrap() calls with proper error propagation using ok_or_else()?
3. Concurrency issues: Improved diff mode thread safety with proper error handling and RAII guards
4. Security concerns: Added validation for item IDs to prevent path traversal vulnerabilities
5. Database design: Wrapped database operations in transactions for atomicity in save/update modes

Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-09 23:33:06 -03:00
Andrew Phillips
2be895fea5 chore: remove invalid race condition bug risk #11 from PLAN.md
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-09 23:24:26 -03:00
Andrew Phillips
6804429c9f docs: update PLAN.md with refined issue descriptions and solutions
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-09 23:22:13 -03:00
Andrew Phillips
f88897000f docs: add comprehensive code quality issues and fixes plan
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-09 23:10:21 -03:00
Andrew Phillips
84fdffd97d docs: add project plan documentation 2025-08-09 23:10:20 -03:00
26 changed files with 2279 additions and 350 deletions

View File

@@ -40,6 +40,14 @@ sha2 = "0.10.0"
local-ip-address = "0.5.5" local-ip-address = "0.5.5"
dns-lookup = "2.0.2" dns-lookup = "2.0.2"
uzers = "0.11.3" uzers = "0.11.3"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.142"
serde_yaml = "0.9.34"
tokio = { version = "1.0", features = ["full"] }
axum = "0.7"
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "fs", "trace"] }
hyper = { version = "1.0", features = ["full"] }
[dev-dependencies] [dev-dependencies]
tempfile = "3.3.0" tempfile = "3.3.0"

View File

@@ -31,6 +31,7 @@
- `modes/info.rs` - Show detailed item information - `modes/info.rs` - Show detailed item information
- `modes/diff.rs` - Compare two items - `modes/diff.rs` - Compare two items
- `modes/status.rs` - Show system status and capabilities - `modes/status.rs` - Show system status and capabilities
- `modes/server.rs` - REST HTTP server mode with OpenAPI documentation
- `modes/common.rs` - Shared utilities for all modes - `modes/common.rs` - Shared utilities for all modes
### Database Module ### Database Module

81
PLAN.md Normal file
View File

@@ -0,0 +1,81 @@
# Code Quality Issues and Fixes
## Critical Issues
### 1. Memory Safety & Resource Leaks - DONE
**Files affected:** `src/modes/diff.rs`, `src/compression_engine/program.rs`
**Functions affected:** `mode_diff()`, `CompressionEngineProgram::open()`, `CompressionEngineProgram::create()`
**Problem example:** Raw file descriptors converted with `unsafe { std::fs::File::from_raw_fd(fd_write) }` without proper cleanup on errors
**Fix example:** Use RAII wrappers or ensure proper cleanup in Drop implementations and error paths
### 2. Error Handling Problems - DONE
**Files affected:** `src/modes/save.rs`, `src/modes/update.rs`, `src/db.rs`
**Functions affected:** `mode_save()`, `mode_update()`, `get_item()`, `insert_item()`
**Problem example:** `item.id.unwrap()` can panic if item.id is None
**Fix example:** Replace with `item.id.ok_or_else(|| anyhow!("Item missing ID"))?`
### 3. Concurrency Issues - DONE
**Files affected:** `src/modes/diff.rs`, `src/meta_plugin/digest.rs`
**Functions affected:** `mode_diff()`, meta plugin `update()` methods
**Problem example:** In `mode_diff()`, if writer threads panic, resources may not be cleaned up properly: `writer_thread_a.join()` only propagates panic but doesn't ensure file descriptors are closed
**Fix example:** Use RAII guards or ensure cleanup in panic handlers: `let _fd_guard = FileDescriptorGuard::new(fd_write);`
## Design Problems
### 4. Database Design Issues - DONE
**Files affected:** `src/db.rs`, `src/modes/save.rs`, `src/modes/update.rs`
**Functions affected:** `insert_item()`, `update_item()`, `store_meta()`, `set_item_tags()`
**Problem example:** Multiple database operations without transactions can leave partial state
**Fix example:** Wrap related operations in `conn.transaction()` blocks
### 5. Plugin Architecture Flaws
**Files affected:** `src/meta_plugin.rs`, `src/meta_plugin/digest.rs`, `src/meta_plugin/program.rs`
**Functions affected:** `MetaPlugin::create()`, `MetaPlugin::update()`, `MetaPlugin::finalize()`
**Problem example:**
- `create()` returns dummy writer that's never used, inconsistent with actual usage pattern
- `MetaPluginProgram::finalize()` spawns new process instead of reusing existing one
- No validation that meta plugins produce valid output formats
- Plugin errors are silently ignored in save operations
**Fix example:**
- Remove `create()` method and rely only on `update()`/`finalize()` pattern
- Reuse single process per plugin instance for better performance
- Add output validation and proper error propagation
### 6. Security Concerns - DONE
**Files affected:** `src/main.rs`, `src/modes/get.rs`, `src/modes/delete.rs`
**Functions affected:** `main()`, `mode_get()`, `mode_delete()`
**Problem example:** Item IDs used directly in file paths without validation: `item_path.push(item_id.to_string())`
**Fix example:** Validate item IDs are positive integers and sanitize file paths
## Performance Issues
### 7. Inefficient Operations
**Files affected:** `src/modes/save.rs`, `src/compression_engine.rs`
**Functions affected:** `mode_save()`, `CompressionEngine::size()`
**Problem example:** Fixed BUFSIZ buffer (typically 8KB) may not be optimal for all scenarios, especially large files or fast storage
**Fix example:** Use adaptive buffer sizing based on file size or storage characteristics, or allow configuration via environment variable
### 8. I/O Problems
**Files affected:** `src/meta_plugin/program.rs`, `src/compression_engine/program.rs`
**Functions affected:** `MetaPluginProgram::finalize()`, `CompressionEngineProgram::open()`, `CompressionEngineProgram::create()`
**Problem example:** Meta plugin processes can block indefinitely if they hang or produce large output without proper timeouts
**Fix example:** Add timeouts to process operations and non-blocking I/O for meta plugins: `process.wait_timeout(Duration::from_secs(30))`
## Code Quality Issues
### 9. Error Messages
**Files affected:** `src/modes/common.rs`, `src/main.rs`
**Functions affected:** `cmd_args_digest_type()`, `cmd_args_compression_type()`, `main()`
**Problem example:** `format!("Unknown digest type: {}", digest_name)` exposes internal terminology
**Fix example:** `format!("Invalid digest algorithm '{}'. Use 'sha256' or 'md5'", digest_name)`
### 10. Code Organization
**Files affected:** `src/modes/save.rs`, `src/modes/diff.rs`
**Functions affected:** `mode_save()`, `mode_diff()`
**Problem example:** Large functions doing multiple responsibilities
**Fix example:** Split into smaller functions:
- `src/modes/save.rs: mode_save()``setup_compression_and_plugins()`, `process_input_stream()`, `finalize_meta_plugins()`, `save_item_to_database()`
- `src/modes/diff.rs: mode_diff()``validate_diff_args()`, `setup_diff_pipes()`, `spawn_writer_threads()`, `execute_diff_command()`, `handle_diff_output()`
- `src/modes/diff.rs: write_item_to_pipe()``open_item_reader()`, `copy_item_data()`

217
src/common.rs Normal file
View File

@@ -0,0 +1,217 @@
use std::io::Read;
/// Detect if data is binary or text
/// Returns true if data is likely binary, false if likely text
pub fn is_binary(data: &[u8]) -> bool {
if data.is_empty() {
return false;
}
// First check for known binary file signatures
if has_binary_signature(data) {
return true;
}
// Check for UTF-16 BOM (text)
if data.len() >= 2 {
if (data[0] == 0xFF && data[1] == 0xFE) || (data[0] == 0xFE && data[1] == 0xFF) {
return false; // UTF-16 with BOM is text
}
}
// Check for UTF-8 BOM (text)
if data.len() >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
return false; // UTF-8 with BOM is text
}
// Check if it's valid UTF-8
if std::str::from_utf8(data).is_ok() {
// Valid UTF-8, check printable character ratio
return calculate_printable_ratio(data) < 0.7;
}
// Not valid UTF-8, check if it might be UTF-16 without BOM
if looks_like_utf16(data) {
return false; // Likely UTF-16 text
}
// Check for TAR format (special case with no magic number)
if looks_like_tar(data) {
return true;
}
// Final fallback: check printable character ratio
// For 1KB of random data, we expect very few printable characters
calculate_printable_ratio(data) < 0.7
}
/// Check for known binary file signatures
fn has_binary_signature(data: &[u8]) -> bool {
// Define binary file signatures with their minimum required lengths
let signatures: &[(&[u8], usize)] = &[
// Image formats
(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], 8), // PNG
(&[0xFF, 0xD8, 0xFF], 3), // JPEG (various subtypes)
(&[0x47, 0x49, 0x46, 0x38, 0x37, 0x61], 6), // GIF87a
(&[0x47, 0x49, 0x46, 0x38, 0x39, 0x61], 6), // GIF89a
(&[0x42, 0x4D], 2), // BMP
(&[0x00, 0x00, 0x01, 0x00], 4), // ICO
(&[0x49, 0x49, 0x2A, 0x00], 4), // TIFF (little endian)
(&[0x4D, 0x4D, 0x00, 0x2A], 4), // TIFF (big endian)
(&[0x52, 0x49, 0x46, 0x46], 4), // WebP (RIFF container)
(&[0x00, 0x00, 0x00, 0x0C, 0x6A, 0x50, 0x20, 0x20], 8), // JPEG 2000
// Audio/Video formats
(&[0x49, 0x44, 0x33], 3), // MP3 with ID3v2
(&[0xFF, 0xFB], 2), // MP3
(&[0xFF, 0xF3], 2), // MP3
(&[0xFF, 0xF2], 2), // MP3
(&[0x4F, 0x67, 0x67, 0x53], 4), // OGG
(&[0x66, 0x74, 0x79, 0x70], 4), // MP4/M4A/MOV (at offset 4)
(&[0x52, 0x49, 0x46, 0x46], 4), // WAV/AVI (RIFF)
(&[0x46, 0x4C, 0x56], 3), // FLV
(&[0x1A, 0x45, 0xDF, 0xA3], 4), // MKV/WebM
// Archive formats
(&[0x50, 0x4B, 0x03, 0x04], 4), // ZIP
(&[0x50, 0x4B, 0x05, 0x06], 4), // ZIP (empty)
(&[0x50, 0x4B, 0x07, 0x08], 4), // ZIP (spanned)
(&[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00], 7), // RAR v1.5+
(&[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x01, 0x00], 8), // RAR v5.0+
(&[0x1F, 0x8B], 2), // GZIP
(&[0x42, 0x5A, 0x68], 3), // BZIP2
(&[0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00], 6), // XZ
(&[0x28, 0xB5, 0x2F, 0xFD], 4), // Zstandard
(&[0x04, 0x22, 0x4D, 0x18], 4), // LZ4
(&[0x1F, 0x9D], 2), // LZW compressed
(&[0x1F, 0xA0], 2), // LZH compressed
(&[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], 6), // 7-Zip
// Document formats
(&[0x25, 0x50, 0x44, 0x46], 4), // PDF
(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1], 8), // MS Office (OLE)
(&[0x50, 0x4B, 0x03, 0x04], 4), // Office Open XML (also ZIP)
(&[0x7B, 0x5C, 0x72, 0x74, 0x66], 5), // RTF
// Executables and object files
(&[0x7F, 0x45, 0x4C, 0x46], 4), // ELF
(&[0x4D, 0x5A], 2), // Windows PE/DOS
(&[0xCA, 0xFE, 0xBA, 0xBE], 4), // Mach-O (big endian)
(&[0xFE, 0xED, 0xFA, 0xCE], 4), // Mach-O 32-bit (little endian)
(&[0xFE, 0xED, 0xFA, 0xCF], 4), // Mach-O 64-bit (little endian)
(&[0xCE, 0xFA, 0xED, 0xFE], 4), // Mach-O 32-bit (big endian)
(&[0xCF, 0xFA, 0xED, 0xFE], 4), // Mach-O 64-bit (big endian)
(&[0xCA, 0xFE, 0xBA, 0xBE], 4), // Java class file
(&[0xDE, 0xC0, 0x17, 0x0B], 4), // Dalvik executable
// Database formats
(&[0x53, 0x51, 0x4C, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6F, 0x72, 0x6D, 0x61, 0x74, 0x20, 0x33, 0x00], 16), // SQLite
(&[0x00, 0x01, 0x00, 0x00], 4), // Palm Database
// Font formats
(&[0x00, 0x01, 0x00, 0x00, 0x00], 5), // TrueType
(&[0x4F, 0x54, 0x54, 0x4F], 4), // OpenType
(&[0x77, 0x4F, 0x46, 0x46], 4), // WOFF
(&[0x77, 0x4F, 0x46, 0x32], 4), // WOFF2
// Virtual machine formats
(&[0x76, 0x6D, 0x64, 0x6B], 4), // VMDK
(&[0x3C, 0x3C, 0x3C, 0x20, 0x4F, 0x72, 0x61, 0x63, 0x6C, 0x65, 0x20, 0x56, 0x4D, 0x20, 0x56, 0x69, 0x72, 0x74, 0x75, 0x61, 0x6C, 0x42, 0x6F, 0x78, 0x20, 0x44, 0x69, 0x73, 0x6B, 0x20, 0x49, 0x6D, 0x61, 0x67, 0x65, 0x20, 0x3E, 0x3E, 0x3E], 39), // VirtualBox VDI
// Disk image formats
(&[0xEB, 0x3C, 0x90], 3), // FAT12/16/32
(&[0xEB, 0x58, 0x90], 3), // FAT32
(&[0x55, 0xAA], 2), // Boot sector (at offset 510)
// Other binary formats
(&[0x21, 0x3C, 0x61, 0x72, 0x63, 0x68, 0x3E, 0x0A], 8), // AR archive
(&[0x78, 0x01], 2), // zlib (default compression)
(&[0x78, 0x9C], 2), // zlib (best compression)
(&[0x78, 0xDA], 2), // zlib (fast compression)
(&[0x62, 0x76, 0x78, 0x32], 4), // LZFSE
];
for (signature, min_len) in signatures {
if data.len() >= *min_len && data.starts_with(signature) {
return true;
}
}
// Special case: check for ftyp box in MP4/MOV files (at offset 4)
if data.len() >= 8 && &data[4..8] == b"ftyp" {
return true;
}
false
}
/// Check if data looks like UTF-16 without BOM
fn looks_like_utf16(data: &[u8]) -> bool {
if data.len() < 4 || data.len() % 2 != 0 {
return false;
}
let mut zero_count = 0;
let pairs = data.len() / 2;
// Check if every other byte is zero (indicating UTF-16)
for i in 0..pairs {
if data[i * 2 + 1] == 0 {
zero_count += 1;
}
}
// If more than 50% of odd positions are zero, might be UTF-16
zero_count as f64 / pairs as f64 > 0.5
}
/// Check if data looks like a TAR archive
fn looks_like_tar(data: &[u8]) -> bool {
if data.len() < 512 {
return false;
}
// TAR header structure validation
// Filename should not start with null
if data[0] == 0 {
return false;
}
// Check file mode field (should be octal digits)
for i in 100..108 {
if data[i] != 0 && (data[i] < b'0' || data[i] > b'7') && data[i] != b' ' {
return false;
}
}
// Check checksum field (should be octal digits or spaces)
for i in 148..156 {
if data[i] != 0 && (data[i] < b'0' || data[i] > b'7') && data[i] != b' ' {
return false;
}
}
// Check magic field for POSIX TAR
if data.len() >= 265 {
let magic = &data[257..262];
if magic == b"ustar" {
return true;
}
}
// Additional heuristic: check if the structure looks reasonable
let has_reasonable_structure =
data[0] != 0 && // Filename starts
data[100..108].iter().all(|&b| b == 0 || (b >= b'0' && b <= b'7') || b == b' '); // Mode field
has_reasonable_structure
}
/// Calculate the ratio of printable characters in the data
fn calculate_printable_ratio(data: &[u8]) -> f64 {
let printable_count = data.iter().filter(|&&b| {
b.is_ascii_graphic() || b.is_ascii_whitespace()
}).count();
printable_count as f64 / data.len() as f64
}

View File

@@ -6,10 +6,52 @@ use std::fs::File;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::{Command, Stdio}; use std::process::{Child, Command, Stdio};
use crate::compression_engine::CompressionEngine; use crate::compression_engine::CompressionEngine;
pub struct ProgramReader {
process: Child,
stdout: Option<std::process::ChildStdout>,
}
impl Read for ProgramReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.stdout.as_mut().unwrap().read(buf)
}
}
impl Drop for ProgramReader {
fn drop(&mut self) {
// Ensure the process is waited on to prevent zombie processes
let _ = self.process.wait();
}
}
pub struct ProgramWriter {
process: Child,
stdin: Option<std::process::ChildStdin>,
}
impl Write for ProgramWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.stdin.as_mut().unwrap().write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
self.stdin.as_mut().unwrap().flush()
}
}
impl Drop for ProgramWriter {
fn drop(&mut self) {
// Close stdin to signal EOF to the child process
drop(self.stdin.take());
// Ensure the process is waited on to prevent zombie processes
let _ = self.process.wait();
}
}
#[derive(Debug, Eq, PartialEq, Clone)] #[derive(Debug, Eq, PartialEq, Clone)]
pub struct CompressionEngineProgram { pub struct CompressionEngineProgram {
pub program: String, pub program: String,
@@ -72,7 +114,7 @@ impl CompressionEngine for CompressionEngineProgram {
let file = File::open(file_path).context("Unable to open file for reading")?; let file = File::open(file_path).context("Unable to open file for reading")?;
let process = Command::new(program.clone()) let mut process = Command::new(program.clone())
.args(args.clone()) .args(args.clone())
.stdin(file) .stdin(file)
.stdout(Stdio::piped()) .stdout(Stdio::piped())
@@ -82,11 +124,19 @@ impl CompressionEngine for CompressionEngineProgram {
program, program,
args args
))?; ))?;
Ok(Box::new(process.stdout.unwrap()))
let stdout = process.stdout.take().ok_or_else(|| {
anyhow!("Failed to capture stdout from child process")
})?;
Ok(Box::new(ProgramReader {
process,
stdout: Some(stdout),
}))
} }
fn create(&self, file_path: PathBuf) -> Result<Box<dyn Write>> { fn create(&self, file_path: PathBuf) -> Result<Box<dyn Write>> {
debug!("COMPRESSION: Writting to {:?} using {:?}", file_path, *self); debug!("COMPRESSION: Writing to {:?} using {:?}", file_path, *self);
let program = self.program.clone(); let program = self.program.clone();
let args = self.compress.clone(); let args = self.compress.clone();
@@ -98,7 +148,7 @@ impl CompressionEngine for CompressionEngineProgram {
let file = File::create(file_path).context("Unable to open file for writing")?; let file = File::create(file_path).context("Unable to open file for writing")?;
let process = Command::new(program.clone()) let mut process = Command::new(program.clone())
.args(args.clone()) .args(args.clone())
.stdin(Stdio::piped()) .stdin(Stdio::piped())
.stdout(file) .stdout(file)
@@ -109,6 +159,13 @@ impl CompressionEngine for CompressionEngineProgram {
args args
))?; ))?;
Ok(Box::new(process.stdin.unwrap())) let stdin = process.stdin.take().ok_or_else(|| {
anyhow!("Failed to capture stdin from child process")
})?;
Ok(Box::new(ProgramWriter {
process,
stdin: Some(stdin),
}))
} }
} }

View File

@@ -2,6 +2,7 @@ use std::path::PathBuf;
use anyhow::{Context, Error, Result, anyhow}; use anyhow::{Context, Error, Result, anyhow};
use clap::*; use clap::*;
use clap::error::ErrorKind;
use log::*; use log::*;
mod modes; mod modes;
@@ -21,11 +22,16 @@ pub mod meta_plugin;
//pub mod item; //pub mod item;
extern crate term; extern crate term;
extern crate serde_json;
extern crate serde_yaml;
extern crate serde;
mod common;
/** /**
* Main struct for command-line arguments. * Main struct for command-line arguments.
*/ */
#[derive(Parser, Debug)] #[derive(Parser, Debug, Clone)]
#[command(author, version, about, long_about = None)] #[command(author, version, about, long_about = None)]
pub struct Args { pub struct Args {
#[command(flatten)] #[command(flatten)]
@@ -42,7 +48,7 @@ pub struct Args {
/** /**
* Struct for mode-specific arguments. * Struct for mode-specific arguments.
*/ */
#[derive(Parser, Debug)] #[derive(Parser, Debug, Clone)]
struct ModeArgs { struct ModeArgs {
#[arg(group("mode"), help_heading("Mode Options"), short, long, conflicts_with_all(["get", "diff", "list", "update", "delete", "info", "status"]))] #[arg(group("mode"), help_heading("Mode Options"), short, long, conflicts_with_all(["get", "diff", "list", "update", "delete", "info", "status"]))]
#[arg(help("Save an item using any tags or metadata provided"))] #[arg(help("Save an item using any tags or metadata provided"))]
@@ -76,15 +82,19 @@ struct ModeArgs {
))] ))]
info: bool, info: bool,
#[arg(group("mode"), help_heading("Mode Options"), short('S'), long, conflicts_with_all(["save", "get", "diff", "list", "update", "delete", "info"]))] #[arg(group("mode"), help_heading("Mode Options"), short('S'), long, conflicts_with_all(["save", "get", "diff", "list", "update", "delete", "info", "server"]))]
#[arg(help("Show status of directories and supported compression algorithms"))] #[arg(help("Show status of directories and supported compression algorithms"))]
status: bool, status: bool,
#[arg(group("mode"), help_heading("Mode Options"), long, conflicts_with_all(["save", "get", "diff", "list", "update", "delete", "info", "status"]))]
#[arg(help("Start REST HTTP server on specified address:port or socket path"))]
server: Option<String>,
} }
/** /**
* Struct for item-specific arguments. * Struct for item-specific arguments.
*/ */
#[derive(Parser, Debug)] #[derive(Parser, Debug, Clone)]
struct ItemArgs { struct ItemArgs {
#[arg(help_heading("Item Options"), short, long, conflicts_with_all(["get", "delete", "status"]))] #[arg(help_heading("Item Options"), short, long, conflicts_with_all(["get", "delete", "status"]))]
#[arg(help( #[arg(help(
@@ -105,10 +115,11 @@ struct ItemArgs {
meta_plugins: Vec<String>, meta_plugins: Vec<String>,
} }
/** /**
* Struct for general options. * Struct for general options.
*/ */
#[derive(Parser, Debug)] #[derive(Parser, Debug, Default, Clone)]
struct OptionsArgs { struct OptionsArgs {
#[arg(long, env("KEEP_DIR"))] #[arg(long, env("KEEP_DIR"))]
#[arg(help("Specify the directory to use for storage"))] #[arg(help("Specify the directory to use for storage"))]
@@ -133,6 +144,17 @@ struct OptionsArgs {
#[arg(short, long)] #[arg(short, long)]
#[arg(help("Do not show any messages"))] #[arg(help("Do not show any messages"))]
quiet: bool, quiet: bool,
#[arg(long, value_enum, default_value("table"))]
#[arg(help("Output format (only works with --info, --status, --list)"))]
output_format: Option<String>,
#[arg(long, env("KEEP_SERVER_PASSWORD"))]
#[arg(help("Password for server authentication (requires --server)"))]
server_password: Option<String>,
#[arg(long, help("Force output even when binary data would be sent to a TTY"))]
force: bool,
} }
/** /**
@@ -149,6 +171,7 @@ enum KeepModes {
Delete, Delete,
Info, Info,
Status, Status,
Server,
} }
/** /**
@@ -242,6 +265,8 @@ fn main() -> Result<(), Error> {
mode = KeepModes::Info; mode = KeepModes::Info;
} else if args.mode.status { } else if args.mode.status {
mode = KeepModes::Status; mode = KeepModes::Status;
} else if args.mode.server.is_some() {
mode = KeepModes::Server;
} }
if mode == KeepModes::Unknown { if mode == KeepModes::Unknown {
@@ -252,6 +277,32 @@ fn main() -> Result<(), Error> {
} }
} }
// Validate output format usage
if let Some(output_format_str) = &args.options.output_format {
if output_format_str != "table" && mode != KeepModes::Info && mode != KeepModes::Status && mode != KeepModes::List {
cmd.error(
ErrorKind::InvalidValue,
"--output-format can only be used with --info, --status, or --list modes"
).exit();
}
}
// Validate human-readable usage
if args.options.human_readable && mode != KeepModes::List && mode != KeepModes::Info {
cmd.error(
ErrorKind::InvalidValue,
"--human-readable can only be used with --list and --info modes"
).exit();
}
// Validate server password usage
if args.options.server_password.is_some() && mode != KeepModes::Server {
cmd.error(
ErrorKind::InvalidValue,
"--server-password can only be used with --server mode"
).exit();
}
debug!("MAIN: args: {:?}", args); debug!("MAIN: args: {:?}", args);
debug!("MAIN: ids: {:?}", ids); debug!("MAIN: ids: {:?}", ids);
debug!("MAIN: tags: {:?}", tags); debug!("MAIN: tags: {:?}", tags);
@@ -306,6 +357,9 @@ fn main() -> Result<(), Error> {
KeepModes::Status => { KeepModes::Status => {
crate::modes::status::mode_status(&mut cmd, &args, data_path, db_path)? crate::modes::status::mode_status(&mut cmd, &args, data_path, db_path)?
} }
KeepModes::Server => {
crate::modes::server::mode_server(&mut cmd, &args, &mut conn, data_path)?
}
_ => todo!(), _ => todo!(),
} }

View File

@@ -9,7 +9,7 @@ pub mod system;
use crate::meta_plugin::program::MetaPluginProgram; use crate::meta_plugin::program::MetaPluginProgram;
use crate::meta_plugin::digest::{DigestSha256MetaPlugin, ReadTimeMetaPlugin, ReadRateMetaPlugin}; use crate::meta_plugin::digest::{DigestSha256MetaPlugin, ReadTimeMetaPlugin, ReadRateMetaPlugin};
use crate::meta_plugin::system::{CwdMetaPlugin, UidMetaPlugin, UserMetaPlugin, GidMetaPlugin, GroupMetaPlugin, ShellMetaPlugin, ShellPidMetaPlugin, KeepPidMetaPlugin, HostnameMetaPlugin, FullHostnameMetaPlugin}; use crate::meta_plugin::system::{CwdMetaPlugin, BinaryMetaPlugin, UidMetaPlugin, UserMetaPlugin, GidMetaPlugin, GroupMetaPlugin, ShellMetaPlugin, ShellPidMetaPlugin, KeepPidMetaPlugin, HostnameMetaPlugin, FullHostnameMetaPlugin};
#[derive(Debug, Eq, PartialEq, Clone, strum::EnumIter, strum::Display, strum::EnumString)] #[derive(Debug, Eq, PartialEq, Clone, strum::EnumIter, strum::Display, strum::EnumString)]
#[strum(ascii_case_insensitive)] #[strum(ascii_case_insensitive)]
@@ -20,6 +20,7 @@ pub enum MetaPluginType {
LineCount, LineCount,
WordCount, WordCount,
Cwd, Cwd,
Binary,
Uid, Uid,
User, User,
Gid, Gid,
@@ -40,6 +41,10 @@ pub trait MetaPlugin {
true true
} }
fn is_internal(&self) -> bool {
false
}
fn create(&self) -> Result<Box<dyn Write>>; fn create(&self) -> Result<Box<dyn Write>>;
fn finalize(&mut self) -> io::Result<String>; fn finalize(&mut self) -> io::Result<String>;
@@ -62,6 +67,7 @@ pub fn get_meta_plugin(meta_plugin_type: MetaPluginType) -> Box<dyn MetaPlugin>
MetaPluginType::LineCount => Box::new(MetaPluginProgram::new("wc", vec!["-l"], "line_count".to_string(), true)), MetaPluginType::LineCount => Box::new(MetaPluginProgram::new("wc", vec!["-l"], "line_count".to_string(), true)),
MetaPluginType::WordCount => Box::new(MetaPluginProgram::new("wc", vec!["-w"], "word_count".to_string(), true)), MetaPluginType::WordCount => Box::new(MetaPluginProgram::new("wc", vec!["-w"], "word_count".to_string(), true)),
MetaPluginType::Cwd => Box::new(CwdMetaPlugin::new()), MetaPluginType::Cwd => Box::new(CwdMetaPlugin::new()),
MetaPluginType::Binary => Box::new(BinaryMetaPlugin::new()),
MetaPluginType::Uid => Box::new(UidMetaPlugin::new()), MetaPluginType::Uid => Box::new(UidMetaPlugin::new()),
MetaPluginType::User => Box::new(UserMetaPlugin::new()), MetaPluginType::User => Box::new(UserMetaPlugin::new()),
MetaPluginType::Gid => Box::new(GidMetaPlugin::new()), MetaPluginType::Gid => Box::new(GidMetaPlugin::new()),

View File

@@ -22,6 +22,10 @@ impl DigestSha256MetaPlugin {
} }
impl MetaPlugin for DigestSha256MetaPlugin { impl MetaPlugin for DigestSha256MetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> { fn create(&self) -> Result<Box<dyn Write>> {
// For meta plugins, we don't actually create a writer since we're buffering data internally // For meta plugins, we don't actually create a writer since we're buffering data internally
// This method is required by the trait but not used in the same way as digest engines // This method is required by the trait but not used in the same way as digest engines
@@ -72,6 +76,10 @@ impl ReadTimeMetaPlugin {
} }
impl MetaPlugin for ReadTimeMetaPlugin { impl MetaPlugin for ReadTimeMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> { fn create(&self) -> Result<Box<dyn Write>> {
// For meta plugins, we don't actually create a writer since we're buffering data internally // For meta plugins, we don't actually create a writer since we're buffering data internally
Ok(Box::new(DummyWriter)) Ok(Box::new(DummyWriter))
@@ -115,6 +123,10 @@ impl ReadRateMetaPlugin {
} }
impl MetaPlugin for ReadRateMetaPlugin { impl MetaPlugin for ReadRateMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> { fn create(&self) -> Result<Box<dyn Write>> {
// For meta plugins, we don't actually create a writer since we're buffering data internally // For meta plugins, we don't actually create a writer since we're buffering data internally
Ok(Box::new(DummyWriter)) Ok(Box::new(DummyWriter))

View File

@@ -41,6 +41,10 @@ impl MetaPlugin for MetaPluginProgram {
self.supported self.supported
} }
fn is_internal(&self) -> bool {
false
}
fn create(&self) -> Result<Box<dyn Write>> { fn create(&self) -> Result<Box<dyn Write>> {
debug!("META: Writing using {:?}", *self); debug!("META: Writing using {:?}", *self);

View File

@@ -8,6 +8,7 @@ use std::env;
use std::process; use std::process;
use uzers::{get_current_uid, get_current_gid, get_current_username, get_current_groupname}; use uzers::{get_current_uid, get_current_gid, get_current_username, get_current_groupname};
use crate::common::is_binary;
use crate::meta_plugin::MetaPlugin; use crate::meta_plugin::MetaPlugin;
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
@@ -15,6 +16,52 @@ pub struct CwdMetaPlugin {
meta_name: String, meta_name: String,
} }
#[derive(Debug, Clone, Default)]
pub struct BinaryMetaPlugin {
meta_name: String,
buffer: Vec<u8>,
max_buffer_size: usize,
}
impl BinaryMetaPlugin {
pub fn new() -> BinaryMetaPlugin {
BinaryMetaPlugin {
meta_name: "binary".to_string(),
buffer: Vec::new(),
max_buffer_size: 4096, // 4KB
}
}
}
impl MetaPlugin for BinaryMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> {
Ok(Box::new(io::sink()))
}
fn finalize(&mut self) -> io::Result<String> {
let is_binary = is_binary(&self.buffer);
Ok(if is_binary { "true".to_string() } else { "false".to_string() })
}
fn update(&mut self, data: &[u8]) {
// Only collect up to max_buffer_size
let remaining_capacity = self.max_buffer_size.saturating_sub(self.buffer.len());
if remaining_capacity > 0 {
let bytes_to_copy = std::cmp::min(data.len(), remaining_capacity);
self.buffer.extend_from_slice(&data[..bytes_to_copy]);
}
}
fn meta_name(&mut self) -> String {
self.meta_name.clone()
}
}
impl CwdMetaPlugin { impl CwdMetaPlugin {
pub fn new() -> CwdMetaPlugin { pub fn new() -> CwdMetaPlugin {
CwdMetaPlugin { CwdMetaPlugin {
@@ -24,6 +71,10 @@ impl CwdMetaPlugin {
} }
impl MetaPlugin for CwdMetaPlugin { impl MetaPlugin for CwdMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> { fn create(&self) -> Result<Box<dyn Write>> {
Ok(Box::new(io::sink())) Ok(Box::new(io::sink()))
} }
@@ -58,6 +109,10 @@ impl UidMetaPlugin {
} }
impl MetaPlugin for UidMetaPlugin { impl MetaPlugin for UidMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> { fn create(&self) -> Result<Box<dyn Write>> {
Ok(Box::new(io::sink())) Ok(Box::new(io::sink()))
} }
@@ -89,6 +144,10 @@ impl UserMetaPlugin {
} }
impl MetaPlugin for UserMetaPlugin { impl MetaPlugin for UserMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> { fn create(&self) -> Result<Box<dyn Write>> {
Ok(Box::new(io::sink())) Ok(Box::new(io::sink()))
} }
@@ -123,6 +182,10 @@ impl GidMetaPlugin {
} }
impl MetaPlugin for GidMetaPlugin { impl MetaPlugin for GidMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> { fn create(&self) -> Result<Box<dyn Write>> {
Ok(Box::new(io::sink())) Ok(Box::new(io::sink()))
} }
@@ -154,6 +217,10 @@ impl GroupMetaPlugin {
} }
impl MetaPlugin for GroupMetaPlugin { impl MetaPlugin for GroupMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> { fn create(&self) -> Result<Box<dyn Write>> {
Ok(Box::new(io::sink())) Ok(Box::new(io::sink()))
} }
@@ -188,6 +255,10 @@ impl ShellMetaPlugin {
} }
impl MetaPlugin for ShellMetaPlugin { impl MetaPlugin for ShellMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> { fn create(&self) -> Result<Box<dyn Write>> {
Ok(Box::new(io::sink())) Ok(Box::new(io::sink()))
} }
@@ -222,6 +293,10 @@ impl ShellPidMetaPlugin {
} }
impl MetaPlugin for ShellPidMetaPlugin { impl MetaPlugin for ShellPidMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> { fn create(&self) -> Result<Box<dyn Write>> {
Ok(Box::new(io::sink())) Ok(Box::new(io::sink()))
} }
@@ -256,6 +331,10 @@ impl KeepPidMetaPlugin {
} }
impl MetaPlugin for KeepPidMetaPlugin { impl MetaPlugin for KeepPidMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> { fn create(&self) -> Result<Box<dyn Write>> {
Ok(Box::new(io::sink())) Ok(Box::new(io::sink()))
} }
@@ -287,6 +366,10 @@ impl HostnameMetaPlugin {
} }
impl MetaPlugin for HostnameMetaPlugin { impl MetaPlugin for HostnameMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> { fn create(&self) -> Result<Box<dyn Write>> {
Ok(Box::new(io::sink())) Ok(Box::new(io::sink()))
} }
@@ -321,6 +404,10 @@ impl FullHostnameMetaPlugin {
} }
impl MetaPlugin for FullHostnameMetaPlugin { impl MetaPlugin for FullHostnameMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> { fn create(&self) -> Result<Box<dyn Write>> {
Ok(Box::new(io::sink())) Ok(Box::new(io::sink()))
} }

View File

@@ -1,20 +1,16 @@
use crate::Args; use crate::Args;
use crate::compression_engine::CompressionType; use crate::compression_engine::CompressionType;
use crate::db::Item;
use crate::db::Meta;
use crate::db::store_meta;
use crate::meta_plugin::MetaPluginType; use crate::meta_plugin::MetaPluginType;
use clap::Command; use clap::Command;
use clap::error::ErrorKind; use clap::error::ErrorKind;
use humansize::{BINARY, FormatSizeOptions};
use log::debug; use log::debug;
use prettytable::format::TableFormat; use prettytable::format::TableFormat;
use regex::Regex; use regex::Regex;
use rusqlite::Connection;
use std::collections::HashMap; use std::collections::HashMap;
use std::env; use std::env;
use std::str::FromStr; use std::str::FromStr;
use strum::IntoEnumIterator; use strum::IntoEnumIterator;
use serde::{Deserialize, Serialize};
pub fn get_meta_from_env() -> HashMap<String, String> { pub fn get_meta_from_env() -> HashMap<String, String> {
debug!("COMMON: Getting meta from KEEP_META_*"); debug!("COMMON: Getting meta from KEEP_META_*");
@@ -34,8 +30,28 @@ pub fn get_meta_from_env() -> HashMap<String, String> {
} }
pub fn format_size_human_readable(size: u64) -> String { pub fn format_size_human_readable(size: u64) -> String {
let options = FormatSizeOptions::from(BINARY).decimal_places(1); const UNITS: &[&str] = &["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei"];
humansize::format_size(size, options) const THRESHOLD: u64 = 1024;
if size == 0 {
return "0".to_string();
}
let mut size_f = size as f64;
let mut unit_index = 0;
while size_f >= THRESHOLD as f64 && unit_index < UNITS.len() - 1 {
size_f /= THRESHOLD as f64;
unit_index += 1;
}
if unit_index == 0 {
format!("{}", size)
} else if size_f.fract() == 0.0 {
format!("{}{}", size_f as u64, UNITS[unit_index])
} else {
format!("{:.1}{}", size_f, UNITS[unit_index])
}
} }
pub fn format_size(size: u64, human_readable: bool) -> String { pub fn format_size(size: u64, human_readable: bool) -> String {
@@ -107,38 +123,6 @@ pub fn get_digest_type_meta(digest_type: MetaPluginType) -> String {
format!("digest_{}", digest_type.to_string().to_lowercase()) format!("digest_{}", digest_type.to_string().to_lowercase())
} }
pub fn store_item_meta_value(
conn: &mut Connection,
item: Item,
meta_name: String,
meta_value: String,
) -> Result<(), anyhow::Error> {
// Save digest to meta
let meta = Meta {
id: item.id.unwrap(),
name: meta_name,
value: meta_value,
};
store_meta(conn, meta)?;
Ok(())
}
pub fn store_item_digest_value(
conn: &mut Connection,
item: Item,
digest_type: MetaPluginType,
digest_value: String,
) -> Result<(), anyhow::Error> {
// Save digest to meta
let digest_meta_name = get_digest_type_meta(digest_type);
let digest_meta = Meta {
id: item.id.unwrap(),
name: digest_meta_name,
value: digest_value,
};
store_meta(conn, digest_meta)?;
Ok(())
}
pub fn cmd_args_digest_type(cmd: &mut Command, args: &Args) -> MetaPluginType { pub fn cmd_args_digest_type(cmd: &mut Command, args: &Args) -> MetaPluginType {
let digest_name = args let digest_name = args
@@ -151,7 +135,7 @@ pub fn cmd_args_digest_type(cmd: &mut Command, args: &Args) -> MetaPluginType {
if digest_type_opt.is_err() { if digest_type_opt.is_err() {
cmd.error( cmd.error(
ErrorKind::InvalidValue, ErrorKind::InvalidValue,
format!("Unknown digest type: {}", digest_name), format!("Invalid digest algorithm '{}'. Use 'sha256' or 'md5'", digest_name),
) )
.exit(); .exit();
} }
@@ -170,7 +154,7 @@ pub fn cmd_args_compression_type(cmd: &mut Command, args: &Args) -> CompressionT
if compression_type_opt.is_err() { if compression_type_opt.is_err() {
cmd.error( cmd.error(
ErrorKind::InvalidValue, ErrorKind::InvalidValue,
format!("Unknown compression type: {}", compression_name), format!("Invalid compression algorithm '{}'. Supported algorithms: lz4, gzip, xz, zstd", compression_name),
) )
.exit(); .exit();
} }
@@ -178,6 +162,33 @@ pub fn cmd_args_compression_type(cmd: &mut Command, args: &Args) -> CompressionT
compression_type_opt.unwrap() compression_type_opt.unwrap()
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OutputFormat {
Table,
Json,
Yaml,
}
impl FromStr for OutputFormat {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"table" => Ok(OutputFormat::Table),
"json" => Ok(OutputFormat::Json),
"yaml" => Ok(OutputFormat::Yaml),
_ => Err(anyhow::anyhow!("Invalid output format. Supported formats: table, json, yaml")),
}
}
}
pub fn get_output_format(args: &Args) -> OutputFormat {
args.options.output_format
.as_ref()
.and_then(|s| OutputFormat::from_str(s).ok())
.unwrap_or(OutputFormat::Table)
}
pub fn cmd_args_meta_plugin_types(cmd: &mut Command, args: &Args) -> Vec<MetaPluginType> { pub fn cmd_args_meta_plugin_types(cmd: &mut Command, args: &Args) -> Vec<MetaPluginType> {
let mut meta_plugin_types = Vec::new(); let mut meta_plugin_types = Vec::new();

View File

@@ -35,6 +35,11 @@ pub fn mode_delete(
debug!("MAIN: Found item {:?}", item); debug!("MAIN: Found item {:?}", item);
db::delete_item(conn, item)?; db::delete_item(conn, item)?;
// Validate that item ID is positive to prevent path traversal issues
if *item_id <= 0 {
return Err(anyhow!("Invalid item ID: {}", item_id));
}
let mut item_path = data_path.clone(); let mut item_path = data_path.clone();
item_path.push(item_id.to_string()); item_path.push(item_id.to_string());

View File

@@ -1,25 +1,10 @@
use crate::compression_engine::{CompressionType, get_compression_engine}; use anyhow::{anyhow, Result};
use libc::c_int;
use std::path::PathBuf;
use std::str::FromStr;
use anyhow::{Result, anyhow};
use clap::Command; use clap::Command;
use nix::Error as NixError;
use nix::fcntl::FdFlag;
use nix::unistd::{close, pipe};
use std::io::Read; use std::io::Read;
use std::os::fd::FromRawFd; use std::os::fd::FromRawFd;
use std::process::Stdio; use std::str::FromStr;
pub fn mode_diff( fn validate_diff_args(cmd: &mut Command, ids: &Vec<i64>, tags: &Vec<String>) {
cmd: &mut Command,
_args: &crate::Args,
ids: &mut Vec<i64>,
tags: &mut Vec<String>,
conn: &mut rusqlite::Connection,
data_path: PathBuf,
) -> Result<()> {
if !tags.is_empty() { if !tags.is_empty() {
cmd.error( cmd.error(
clap::error::ErrorKind::InvalidValue, clap::error::ErrorKind::InvalidValue,
@@ -34,57 +19,107 @@ pub fn mode_diff(
) )
.exit(); .exit();
} }
}
fn fetch_and_validate_items(
conn: &mut rusqlite::Connection,
ids: &Vec<i64>,
) -> Result<(crate::db::Item, crate::db::Item), anyhow::Error> {
// Fetch items, ensuring they exist. // Fetch items, ensuring they exist.
let item_a = crate::db::get_item(conn, ids[0])? let item_a = crate::db::get_item(conn, ids[0])?
.ok_or_else(|| anyhow!("Unable to find first item (ID: {}) in database", ids[0]))?; .ok_or_else(|| anyhow::anyhow!("Unable to find first item (ID: {}) in database", ids[0]))?;
let item_b = crate::db::get_item(conn, ids[1])? let item_b = crate::db::get_item(conn, ids[1])?
.ok_or_else(|| anyhow!("Unable to find second item (ID: {}) in database", ids[1]))?; .ok_or_else(|| anyhow::anyhow!("Unable to find second item (ID: {}) in database", ids[1]))?;
log::debug!("MAIN: Found item A {:?}", item_a); log::debug!("MAIN: Found item A {:?}", item_a);
log::debug!("MAIN: Found item B {:?}", item_b); log::debug!("MAIN: Found item B {:?}", item_b);
let item_a_tags: Vec<String> = crate::db::get_item_tags(conn, &item_a)? let item_a_id = item_a.id.ok_or_else(|| anyhow!("Item A missing ID"))?;
.into_iter() let item_b_id = item_b.id.ok_or_else(|| anyhow!("Item B missing ID"))?;
.map(|x| x.name)
.collect();
let item_b_tags: Vec<String> = crate::db::get_item_tags(conn, &item_b)? // Validate that item IDs are positive to prevent path traversal issues
if item_a_id <= 0 || item_b_id <= 0 {
return Err(anyhow::anyhow!("Invalid item ID: {} or {}", item_a_id, item_b_id));
}
Ok((item_a, item_b))
}
fn get_item_tags(conn: &mut rusqlite::Connection, item: &crate::db::Item) -> Result<Vec<String>, anyhow::Error> {
let tags: Vec<String> = crate::db::get_item_tags(conn, item)?
.into_iter() .into_iter()
.map(|x| x.name) .map(|x| x.name)
.collect(); .collect();
Ok(tags)
}
fn setup_diff_paths_and_compression(
data_path: &std::path::PathBuf,
item_a: &crate::db::Item,
item_b: &crate::db::Item,
) -> Result<(std::path::PathBuf, crate::compression_engine::CompressionType, std::path::PathBuf, crate::compression_engine::CompressionType), anyhow::Error> {
let item_a_id = item_a.id.ok_or_else(|| anyhow::anyhow!("Item A missing ID"))?;
let item_b_id = item_b.id.ok_or_else(|| anyhow::anyhow!("Item B missing ID"))?;
let mut item_path_a = data_path.clone(); let mut item_path_a = data_path.clone();
item_path_a.push(item_a.id.unwrap().to_string()); // id.unwrap() is safe due to ok_or_else item_path_a.push(item_a_id.to_string());
let compression_type_a = CompressionType::from_str(&item_a.compression)?; let compression_type_a = crate::compression_engine::CompressionType::from_str(&item_a.compression)?;
log::debug!("MAIN: Item A has compression type {:?}", compression_type_a); log::debug!("MAIN: Item A has compression type {:?}", compression_type_a);
let mut item_path_b = data_path.clone(); let mut item_path_b = data_path.clone();
item_path_b.push(item_b.id.unwrap().to_string()); item_path_b.push(item_b_id.to_string());
let compression_type_b = CompressionType::from_str(&item_b.compression)?; let compression_type_b = crate::compression_engine::CompressionType::from_str(&item_b.compression)?;
log::debug!("MAIN: Item B has compression type {:?}", compression_type_b); log::debug!("MAIN: Item B has compression type {:?}", compression_type_b);
Ok((item_path_a, compression_type_a, item_path_b, compression_type_b))
}
fn setup_diff_pipes() -> Result<((libc::c_int, libc::c_int), (libc::c_int, libc::c_int)), anyhow::Error> {
use nix::unistd::pipe;
use nix::Error as NixError;
// Create pipes for diff's input // Create pipes for diff's input
let (fd_a_read, fd_a_write) = let (fd_a_read, fd_a_write) =
pipe().map_err(|e: NixError| anyhow!("Failed to create pipe A: {}", e))?; pipe().map_err(|e: NixError| anyhow::anyhow!("Failed to create pipe A: {}", e))?;
let (fd_b_read, fd_b_write) = let (fd_b_read, fd_b_write) =
pipe().map_err(|e: NixError| anyhow!("Failed to create pipe B: {}", e))?; pipe().map_err(|e: NixError| anyhow::anyhow!("Failed to create pipe B: {}", e))?;
// Set FD_CLOEXEC on write ends. While they are consumed by File::from_raw_fd, Ok(((fd_a_read, fd_a_write), (fd_b_read, fd_b_write)))
// it's good practice if the raw FDs were to be handled further before that. }
// For this specific code, since from_raw_fd takes ownership immediately, this is less critical
// but doesn't hurt. fn setup_fd_guards(fd_a_read: libc::c_int, fd_b_read: libc::c_int) -> (FdGuard, FdGuard) {
nix::fcntl::fcntl( // Wrap file descriptors in RAII guards
let fd_a_read_guard = FdGuard::new(fd_a_read);
let fd_b_read_guard = FdGuard::new(fd_b_read);
(fd_a_read_guard, fd_b_read_guard)
}
fn set_fd_cloexec(fd_a_write: libc::c_int, fd_b_write: libc::c_int) -> Result<(), anyhow::Error> {
use nix::fcntl::{fcntl, FcntlArg, FdFlag};
// Set FD_CLOEXEC on write ends
fcntl(
fd_a_write, fd_a_write,
nix::fcntl::FcntlArg::F_SETFD(FdFlag::FD_CLOEXEC), FcntlArg::F_SETFD(FdFlag::FD_CLOEXEC),
) )
.map_err(|e| anyhow!("Failed to set FD_CLOEXEC on fd_a_write: {}", e))?; .map_err(|e| anyhow::anyhow!("Failed to set FD_CLOEXEC on fd_a_write: {}", e))?;
nix::fcntl::fcntl( fcntl(
fd_b_write, fd_b_write,
nix::fcntl::FcntlArg::F_SETFD(FdFlag::FD_CLOEXEC), FcntlArg::F_SETFD(FdFlag::FD_CLOEXEC),
) )
.map_err(|e| anyhow!("Failed to set FD_CLOEXEC on fd_b_write: {}", e))?; .map_err(|e| anyhow::anyhow!("Failed to set FD_CLOEXEC on fd_b_write: {}", e))?;
Ok(())
}
fn spawn_diff_process(
item_a_id: i64,
item_a_tags: Vec<String>,
item_b_id: i64,
item_b_tags: Vec<String>,
fd_a_read: libc::c_int,
fd_b_read: libc::c_int,
) -> Result<std::process::Child, anyhow::Error> {
log::debug!("MAIN: Creating child process for diff"); log::debug!("MAIN: Creating child process for diff");
let mut diff_command = std::process::Command::new("diff"); let mut diff_command = std::process::Command::new("diff");
diff_command diff_command
@@ -92,28 +127,78 @@ pub fn mode_diff(
.arg("--label") .arg("--label")
.arg(format!( .arg(format!(
"Keep item A: {} {}", "Keep item A: {} {}",
item_a.id.unwrap(), item_a_id,
item_a_tags.join(" ") item_a_tags.join(" ")
)) ))
.arg(format!("/dev/fd/{}", fd_a_read)) .arg(format!("/dev/fd/{}", fd_a_read))
.arg("--label") .arg("--label")
.arg(format!( .arg(format!(
"Keep item B: {} {}", "Keep item B: {} {}",
item_b.id.unwrap(), item_b_id,
item_b_tags.join(" ") item_b_tags.join(" ")
)) ))
.arg(format!("/dev/fd/{}", fd_b_read)) .arg(format!("/dev/fd/{}", fd_b_read))
.stdin(Stdio::null()) .stdin(std::process::Stdio::null())
.stdout(Stdio::piped()) .stdout(std::process::Stdio::piped())
.stderr(Stdio::piped()); .stderr(std::process::Stdio::piped());
let mut child_process = diff_command let child_process = diff_command
.spawn() .spawn()
.map_err(|e| anyhow!("Failed to execute diff command: {}", e))?; .map_err(|e| anyhow::anyhow!("Failed to execute diff command: {}", e))?;
close(fd_a_read).map_err(|e| anyhow!("Failed to close fd_a_read in parent: {}", e))?; Ok(child_process)
close(fd_b_read).map_err(|e| anyhow!("Failed to close fd_b_read in parent: {}", e))?; }
// RAII guard for file descriptors to ensure they're closed
struct FdGuard {
fd: libc::c_int,
}
impl FdGuard {
fn new(fd: libc::c_int) -> Self {
Self { fd }
}
}
impl Drop for FdGuard {
fn drop(&mut self) {
let _ = nix::unistd::close(self.fd);
}
}
// Create a function to write item data to a pipe
fn write_item_to_pipe(
item_path: std::path::PathBuf,
compression_type: crate::compression_engine::CompressionType,
pipe_writer_raw: std::fs::File,
) -> Result<(), anyhow::Error> {
use std::io::BufWriter;
let mut buffered_pipe_writer = BufWriter::new(pipe_writer_raw);
let engine =
crate::compression_engine::get_compression_engine(compression_type).expect("Unable to get compression engine");
log::debug!("THREAD: Sending item to diff");
engine
.copy(item_path, &mut buffered_pipe_writer)
.map_err(|e| anyhow::anyhow!("Failed to copy/compress item: {}", e))?;
log::debug!("THREAD: Done sending item to diff");
Ok(())
}
// Function to spawn a writer thread for an item
fn spawn_writer_thread(
item_path: std::path::PathBuf,
compression_type: crate::compression_engine::CompressionType,
fd_write: libc::c_int,
) -> std::thread::JoinHandle<Result<(), anyhow::Error>> {
let pipe_writer_raw = unsafe { std::fs::File::from_raw_fd(fd_write) };
std::thread::spawn(move || {
write_item_to_pipe(item_path, compression_type, pipe_writer_raw)
})
}
fn execute_diff_command(
child_process: &mut std::process::Child,
) -> Result<(Vec<u8>, Vec<u8>), anyhow::Error> {
let mut child_stdout_pipe = child_process let mut child_stdout_pipe = child_process
.stdout .stdout
.take() .take()
@@ -125,42 +210,6 @@ pub fn mode_diff(
log::debug!("MAIN: Creating threads for diff I/O"); log::debug!("MAIN: Creating threads for diff I/O");
// Create a function to write item data to a pipe
fn write_item_to_pipe(
item_path: PathBuf,
compression_type: CompressionType,
pipe_writer_raw: std::fs::File,
) {
use std::io::BufWriter;
let mut buffered_pipe_writer = BufWriter::new(pipe_writer_raw);
let engine =
get_compression_engine(compression_type).expect("Unable to get compression engine");
log::debug!("THREAD: Sending item to diff");
engine
.copy(item_path, &mut buffered_pipe_writer)
.expect("Failed to copy/compress item");
log::debug!("THREAD: Done sending item to diff");
}
// Function to spawn a writer thread for an item
fn spawn_writer_thread(
item_path: PathBuf,
compression_type: CompressionType,
fd_write: c_int,
) -> std::thread::JoinHandle<()> {
let pipe_writer_raw = unsafe { std::fs::File::from_raw_fd(fd_write) };
std::thread::spawn(move || {
write_item_to_pipe(item_path, compression_type, pipe_writer_raw);
})
}
// Spawn writer threads for both items
let writer_thread_a =
spawn_writer_thread(item_path_a.clone(), compression_type_a.clone(), fd_a_write);
let writer_thread_b =
spawn_writer_thread(item_path_b.clone(), compression_type_b.clone(), fd_b_write);
// Thread to read diff's standard output // Thread to read diff's standard output
let stdout_reader_thread = std::thread::spawn(move || { let stdout_reader_thread = std::thread::spawn(move || {
let mut output_buffer = Vec::new(); let mut output_buffer = Vec::new();
@@ -168,7 +217,7 @@ pub fn mode_diff(
// child_stdout_pipe is a ChildStdout, which implements std::io::Read // child_stdout_pipe is a ChildStdout, which implements std::io::Read
child_stdout_pipe child_stdout_pipe
.read_to_end(&mut output_buffer) .read_to_end(&mut output_buffer)
.map_err(|e| anyhow!("Failed to read diff stdout: {}", e)) .map_err(|e| anyhow::anyhow!("Failed to read diff stdout: {}", e))
.map(|_| output_buffer) // Return the Vec<u8> on success .map(|_| output_buffer) // Return the Vec<u8> on success
}); });
@@ -178,64 +227,33 @@ pub fn mode_diff(
log::debug!("STDERR_READER: Reading diff stderr"); log::debug!("STDERR_READER: Reading diff stderr");
child_stderr_pipe child_stderr_pipe
.read_to_end(&mut error_buffer) .read_to_end(&mut error_buffer)
.map_err(|e| anyhow!("Failed to read diff stderr: {}", e)) .map_err(|e| anyhow::anyhow!("Failed to read diff stderr: {}", e))
.map(|_| error_buffer) .map(|_| error_buffer)
}); });
// Wait for writer threads to complete (meaning all input has been sent to diff)
log::debug!("MAIN: Waiting on writer thread for item A");
if let Err(panic_payload) = writer_thread_a.join() {
// Propagate panic from writer thread
return Err(anyhow!(
"Writer thread for item A (ID: {}) panicked: {:?}",
ids[0],
panic_payload
));
}
log::debug!("MAIN: Writer thread for item A completed.");
log::debug!("MAIN: Waiting on writer thread for item B");
if let Err(panic_payload) = writer_thread_b.join() {
return Err(anyhow!(
"Writer thread for item B (ID: {}) panicked: {:?}",
ids[1],
panic_payload
));
}
log::debug!("MAIN: Writer thread for item B completed.");
log::debug!("MAIN: Done waiting on input-writer threads.");
// Now that all input has been sent and input pipes will be closed by threads exiting,
// wait for the diff child process to terminate.
log::debug!("MAIN: Waiting for diff child process to finish...");
let diff_status = child_process
.wait()
.map_err(|e| anyhow!("Failed to wait on diff command: {}", e))?;
log::debug!(
"MAIN: Diff child process finished with status: {}",
diff_status
);
// Retrieve the captured output from the reader threads. // Retrieve the captured output from the reader threads.
// .join().unwrap() here will panic if the reader thread itself panicked.
// The inner Result is from the read_to_end operation within the thread.
let stdout_capture_result = stdout_reader_thread let stdout_capture_result = stdout_reader_thread
.join() .join()
.unwrap_or_else(|panic_payload| { .map_err(|panic_payload| {
Err(anyhow!( anyhow::anyhow!("Stdout reader thread panicked: {:?}", panic_payload)
"Stdout reader thread panicked: {:?}", })?
panic_payload .map_err(|e| anyhow::anyhow!("Failed to read diff stdout: {}", e))?;
))
})?;
let stderr_capture_result = stderr_reader_thread let stderr_capture_result = stderr_reader_thread
.join() .join()
.unwrap_or_else(|panic_payload| { .map_err(|panic_payload| {
Err(anyhow!( anyhow::anyhow!("Stderr reader thread panicked: {:?}", panic_payload)
"Stderr reader thread panicked: {:?}", })?
panic_payload .map_err(|e| anyhow::anyhow!("Failed to read diff stderr: {}", e))?;
))
})?;
Ok((stdout_capture_result, stderr_capture_result))
}
fn handle_diff_output(
diff_status: std::process::ExitStatus,
stdout_capture_result: Vec<u8>,
stderr_capture_result: Vec<u8>,
) -> Result<(), anyhow::Error> {
// Handle diff's exit status and output // Handle diff's exit status and output
match diff_status.code() { match diff_status.code() {
Some(0) => { Some(0) => {
@@ -267,7 +285,7 @@ pub fn mode_diff(
String::from_utf8_lossy(&stderr_capture_result) String::from_utf8_lossy(&stderr_capture_result)
); );
} }
return Err(anyhow!( return Err(anyhow::anyhow!(
"Diff command reported an error (exit code {})", "Diff command reported an error (exit code {})",
error_code error_code
)); ));
@@ -281,9 +299,107 @@ pub fn mode_diff(
String::from_utf8_lossy(&stderr_capture_result) String::from_utf8_lossy(&stderr_capture_result)
); );
} }
return Err(anyhow!("Diff command terminated by signal")); return Err(anyhow::anyhow!("Diff command terminated by signal"));
} }
} }
Ok(()) Ok(())
} }
pub fn mode_diff(
cmd: &mut Command,
_args: &crate::Args,
ids: &mut Vec<i64>,
tags: &mut Vec<String>,
conn: &mut rusqlite::Connection,
data_path: std::path::PathBuf,
) -> Result<(), anyhow::Error> {
validate_diff_args(cmd, ids, tags);
let (item_a, item_b) = fetch_and_validate_items(conn, ids)?;
let item_a_tags = get_item_tags(conn, &item_a)?;
let item_b_tags = get_item_tags(conn, &item_b)?;
let (item_path_a, compression_type_a, item_path_b, compression_type_b) =
setup_diff_paths_and_compression(&data_path, &item_a, &item_b)?;
let ((fd_a_read, fd_a_write), (fd_b_read, fd_b_write)) = setup_diff_pipes()?;
let (_fd_a_read_guard, _fd_b_read_guard) = setup_fd_guards(fd_a_read, fd_b_read);
set_fd_cloexec(fd_a_write, fd_b_write)?;
let item_a_id = item_a.id.ok_or_else(|| anyhow::anyhow!("Item A missing ID"))?;
let item_b_id = item_b.id.ok_or_else(|| anyhow::anyhow!("Item B missing ID"))?;
let mut child_process = spawn_diff_process(
item_a_id,
item_a_tags,
item_b_id,
item_b_tags,
fd_a_read,
fd_b_read,
)?;
// Close read ends in parent process - they're now guarded by FdGuard
drop(_fd_a_read_guard);
drop(_fd_b_read_guard);
// Spawn writer threads for both items
let writer_thread_a =
spawn_writer_thread(item_path_a.clone(), compression_type_a.clone(), fd_a_write);
let writer_thread_b =
spawn_writer_thread(item_path_b.clone(), compression_type_b.clone(), fd_b_write);
// Wait for writer threads to complete (meaning all input has been sent to diff)
log::debug!("MAIN: Waiting on writer thread for item A");
match writer_thread_a.join() {
Ok(Ok(())) => {
log::debug!("MAIN: Writer thread for item A completed successfully.");
}
Ok(Err(e)) => {
return Err(anyhow::anyhow!("Writer thread for item A failed: {}", e));
}
Err(panic_payload) => {
return Err(anyhow::anyhow!(
"Writer thread for item A (ID: {}) panicked: {:?}",
ids[0],
panic_payload
));
}
}
log::debug!("MAIN: Waiting on writer thread for item B");
match writer_thread_b.join() {
Ok(Ok(())) => {
log::debug!("MAIN: Writer thread for item B completed successfully.");
}
Ok(Err(e)) => {
return Err(anyhow::anyhow!("Writer thread for item B failed: {}", e));
}
Err(panic_payload) => {
return Err(anyhow::anyhow!(
"Writer thread for item B (ID: {}) panicked: {:?}",
ids[1],
panic_payload
));
}
}
log::debug!("MAIN: Done waiting on input-writer threads.");
// Now that all input has been sent and input pipes will be closed by threads exiting,
// wait for the diff child process to terminate.
log::debug!("MAIN: Waiting for diff child process to finish...");
let diff_status = child_process
.wait()
.map_err(|e| anyhow::anyhow!("Failed to wait on diff command: {}", e))?;
log::debug!(
"MAIN: Diff child process finished with status: {}",
diff_status
);
let (stdout_capture_result, stderr_capture_result) = execute_diff_command(&mut child_process)?;
handle_diff_output(diff_status, stdout_capture_result, stderr_capture_result)?;
Ok(())
}

View File

@@ -1,6 +1,8 @@
use anyhow::anyhow; use anyhow::anyhow;
use std::io::{Read, Write};
use crate::compression_engine::{CompressionType, get_compression_engine}; use crate::compression_engine::{CompressionType, get_compression_engine};
use crate::common::is_binary;
use clap::Command; use clap::Command;
use std::path::PathBuf; use std::path::PathBuf;
use std::str::FromStr; use std::str::FromStr;
@@ -34,15 +36,79 @@ pub fn mode_get(
}; };
if let Some(item) = item_maybe { if let Some(item) = item_maybe {
let item_id = item.id.ok_or_else(|| anyhow!("Item missing ID"))?;
// Validate that item ID is positive to prevent path traversal issues
if item_id <= 0 {
return Err(anyhow!("Invalid item ID: {}", item_id));
}
let mut item_path = data_path.clone(); let mut item_path = data_path.clone();
item_path.push(item.id.unwrap().to_string()); item_path.push(item_id.to_string());
// Determine if we should detect binary data
let mut detect_binary = !args.options.force && is_stdout_tty();
// If we're detecting binary and there's binary metadata, check it
if detect_binary {
let item_meta = crate::db::get_item_meta(conn, &item)?;
let binary_meta = item_meta.into_iter().find(|meta| meta.name == "binary");
if let Some(binary_meta) = binary_meta {
if binary_meta.value == "false" {
// If metadata says it's not binary, don't detect
detect_binary = false;
} else if binary_meta.value == "true" {
// If metadata says it's binary, error immediately
return Err(anyhow!("Refusing to output binary data to TTY, use --force to override"));
}
}
}
let compression_type = CompressionType::from_str(&item.compression)?; let compression_type = CompressionType::from_str(&item.compression)?;
let compression_engine = get_compression_engine(compression_type)?; let compression_engine = get_compression_engine(compression_type)?;
compression_engine.cat(item_path.clone())?;
// If we need to detect binary, read first 4KB and check
if detect_binary {
// Open the file through compression engine to read first 4KB
let mut reader = compression_engine.open(item_path.clone())?;
let mut buffer = [0u8; 4096];
let bytes_read = reader.read(&mut buffer)?;
// Check if this data is binary
if is_binary(&buffer[..bytes_read]) {
return Err(anyhow!("Refusing to output binary data to TTY, use --force to override"));
}
// If not binary, output the data we've read
std::io::stdout().write_all(&buffer[..bytes_read])?;
// Continue reading and outputting the rest of the data
let mut stdout = std::io::stdout();
std::io::copy(&mut reader, &mut stdout)?;
} else {
// No binary detection needed, just output the data
compression_engine.cat(item_path.clone())?;
}
Ok(()) Ok(())
} else { } else {
Err(anyhow!("Unable to find matching item in database")) Err(anyhow!("Unable to find matching item in database"))
} }
} }
fn is_stdout_tty() -> bool {
#[cfg(unix)]
unsafe {
libc::isatty(libc::STDOUT_FILENO) != 0
}
#[cfg(windows)]
unsafe {
let stdout_handle = winapi::um::processenv::GetStdHandle(winapi::um::winbase::STD_OUTPUT_HANDLE);
let mut console_mode: winapi::shared::minwindef::DWORD = 0;
winapi::um::consoleapi::GetConsoleMode(stdout_handle, &mut console_mode) != 0
}
// Fallback for non-unix platforms or if we can't determine
#[cfg(not(any(unix, windows)))]
false
}

View File

@@ -1,6 +1,9 @@
use crate::db::Item; use crate::db::Item;
use crate::modes::common::format_size; use crate::modes::common::{format_size, get_output_format, OutputFormat};
use anyhow::anyhow; use anyhow::anyhow;
use serde_json;
use serde_yaml;
use serde::{Deserialize, Serialize};
use clap::Command; use clap::Command;
use clap::error::ErrorKind; use clap::error::ErrorKind;
use std::path::PathBuf; use std::path::PathBuf;
@@ -48,6 +51,20 @@ pub fn mode_info(
} }
} }
#[derive(Serialize, Deserialize)]
struct ItemInfo {
id: i64,
timestamp: String,
path: String,
stream_size: Option<u64>,
stream_size_formatted: String,
compression: String,
file_size: Option<u64>,
file_size_formatted: String,
tags: Vec<String>,
meta: std::collections::HashMap<String, String>,
}
fn show_item( fn show_item(
item: Item, // Using the provided struct definition item: Item, // Using the provided struct definition
args: &crate::Args, args: &crate::Args,
@@ -61,6 +78,12 @@ fn show_item(
.map(|x| x.name) .map(|x| x.name)
.collect(); .collect();
let output_format = get_output_format(args);
if output_format != OutputFormat::Table {
return show_item_structured(item, args, conn, data_path, output_format);
}
let mut table = Table::new(); let mut table = Table::new();
if std::io::stdout().is_terminal() { if std::io::stdout().is_terminal() {
table.set_format(get_format_box_chars_no_border_line_separator()); table.set_format(get_format_box_chars_no_border_line_separator());
@@ -136,3 +159,61 @@ fn show_item(
table.printstd(); table.printstd();
Ok(()) Ok(())
} }
fn show_item_structured(
item: Item,
args: &crate::Args,
conn: &mut rusqlite::Connection,
data_path: PathBuf,
output_format: OutputFormat,
) -> anyhow::Result<()> {
let item_id = item.id.unwrap();
let item_tags: Vec<String> = crate::db::get_item_tags(conn, &item)?
.into_iter()
.map(|x| x.name)
.collect();
let mut item_path_buf = data_path.clone();
item_path_buf.push(item_id.to_string());
let file_size = item_path_buf.metadata().map(|m| m.len()).ok();
let file_size_formatted = match file_size {
Some(size) => format_size(size, args.options.human_readable),
None => "Missing".to_string(),
};
let stream_size_formatted = match item.size {
Some(size) => format_size(size as u64, args.options.human_readable),
None => "Missing".to_string(),
};
let mut meta_map = std::collections::HashMap::new();
for meta in crate::db::get_item_meta(conn, &item)? {
meta_map.insert(meta.name, meta.value);
}
let item_info = ItemInfo {
id: item_id,
timestamp: item.ts.with_timezone(&chrono::Local).format("%F %T %Z").to_string(),
path: item_path_buf.to_str().unwrap_or("").to_string(),
stream_size: item.size.map(|s| s as u64),
stream_size_formatted,
compression: item.compression,
file_size,
file_size_formatted,
tags: item_tags,
meta: meta_map,
};
match output_format {
OutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&item_info)?);
}
OutputFormat::Yaml => {
println!("{}", serde_yaml::to_string(&item_info)?);
}
OutputFormat::Table => unreachable!(),
}
Ok(())
}

View File

@@ -1,6 +1,9 @@
use crate::db::{get_items, get_items_matching}; use crate::db::{get_items, get_items_matching};
use crate::modes::common::ColumnType; use crate::modes::common::ColumnType;
use crate::modes::common::{size_column, string_column}; use crate::modes::common::{size_column, string_column, get_output_format, OutputFormat};
use serde::{Deserialize, Serialize};
use serde_json;
use serde_yaml;
use anyhow::anyhow; use anyhow::anyhow;
use log::debug; use log::debug;
use prettytable::color; use prettytable::color;
@@ -8,6 +11,20 @@ use prettytable::row;
use prettytable::format::Alignment; use prettytable::format::Alignment;
use prettytable::{Attr, Cell, Row, Table}; use prettytable::{Attr, Cell, Row, Table};
#[derive(Serialize, Deserialize)]
struct ListItem {
id: Option<i64>,
time: String,
size: Option<u64>,
size_formatted: String,
compression: String,
file_size: Option<u64>,
file_size_formatted: String,
file_path: String,
tags: Vec<String>,
meta: std::collections::HashMap<String, String>,
}
pub fn mode_list( pub fn mode_list(
cmd: &mut clap::Command, cmd: &mut clap::Command,
args: &crate::Args, args: &crate::Args,
@@ -54,6 +71,12 @@ pub fn mode_list(
// Fetch all metadata for all items in a single query // Fetch all metadata for all items in a single query
let meta_by_item = crate::db::get_meta_for_items(conn, &item_ids)?; let meta_by_item = crate::db::get_meta_for_items(conn, &item_ids)?;
let output_format = get_output_format(args);
if output_format != OutputFormat::Table {
return show_list_structured(items, tags_by_item, meta_by_item, data_path, args, output_format);
}
let mut table = Table::new(); let mut table = Table::new();
table.set_format(*prettytable::format::consts::FORMAT_CLEAN); table.set_format(*prettytable::format::consts::FORMAT_CLEAN);
@@ -166,3 +189,61 @@ pub fn mode_list(
Ok(()) Ok(())
} }
fn show_list_structured(
items: Vec<crate::db::Item>,
tags_by_item: std::collections::HashMap<i64, Vec<String>>,
meta_by_item: std::collections::HashMap<i64, std::collections::HashMap<String, String>>,
data_path: std::path::PathBuf,
args: &crate::Args,
output_format: OutputFormat,
) -> anyhow::Result<()> {
let mut list_items = Vec::new();
for item in items {
let item_id = item.id.unwrap();
let tags = tags_by_item.get(&item_id).cloned().unwrap_or_default();
let meta = meta_by_item.get(&item_id).cloned().unwrap_or_default();
let mut item_path = data_path.clone();
item_path.push(item_id.to_string());
let file_size = item_path.metadata().map(|m| m.len()).ok();
let file_size_formatted = match file_size {
Some(size) => crate::modes::common::format_size(size, args.options.human_readable),
None => "Missing".to_string(),
};
let size_formatted = match item.size {
Some(size) => crate::modes::common::format_size(size as u64, args.options.human_readable),
None => "Unknown".to_string(),
};
let list_item = ListItem {
id: item.id,
time: item.ts.with_timezone(&chrono::Local).format("%F %T").to_string(),
size: item.size.map(|s| s as u64),
size_formatted,
compression: item.compression,
file_size,
file_size_formatted,
file_path: item_path.into_os_string().into_string().unwrap_or_default(),
tags,
meta,
};
list_items.push(list_item);
}
match output_format {
OutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&list_items)?);
}
OutputFormat::Yaml => {
println!("{}", serde_yaml::to_string(&list_items)?);
}
OutputFormat::Table => unreachable!(),
}
Ok(())
}

View File

@@ -5,5 +5,6 @@ pub mod get;
pub mod info; pub mod info;
pub mod list; pub mod list;
pub mod save; pub mod save;
pub mod server;
pub mod status; pub mod status;
pub mod update; pub mod update;

View File

@@ -1,57 +1,47 @@
use anyhow::{Context, Result, anyhow}; use anyhow::{anyhow, Result};
use gethostname::gethostname;
use is_terminal::IsTerminal;
use std::collections::HashMap;
use std::io::{self, Read, Write};
use clap::Command; use clap::Command;
use clap::error::ErrorKind;
use log::debug; use log::debug;
use rusqlite::Connection; use std::io::{Read, Write, IsTerminal};
use std::path::PathBuf;
use crate::compression_engine::get_compression_engine; // Import the missing functions from common module
use crate::db::{self}; use crate::modes::common::{cmd_args_digest_type, cmd_args_compression_type, cmd_args_meta_plugin_types};
use crate::meta_plugin::{MetaPlugin, MetaPluginType, get_meta_plugin};
use crate::modes::common::{cmd_args_compression_type, cmd_args_digest_type, cmd_args_meta_plugin_types, get_meta_from_env, store_item_meta_value};
use chrono::Utc;
pub fn mode_save( fn validate_save_args(cmd: &mut Command, ids: &Vec<i64>) {
cmd: &mut Command,
args: &crate::Args,
ids: &mut Vec<i64>,
tags: &mut Vec<String>,
conn: &mut Connection,
data_path: PathBuf,
) -> Result<()> {
if !ids.is_empty() { if !ids.is_empty() {
cmd.error( cmd.error(
ErrorKind::InvalidValue, clap::error::ErrorKind::InvalidValue,
"ID given, you cannot supply IDs when using --save", "ID given, you cannot supply IDs when using --save",
) )
.exit(); .exit();
} }
}
fn initialize_tags(tags: &mut Vec<String>) {
if tags.is_empty() { if tags.is_empty() {
tags.push("none".to_string()); tags.push("none".to_string());
} }
}
fn setup_compression_and_plugins(
cmd: &mut Command,
args: &crate::Args,
) -> (crate::compression_engine::CompressionType, Box<dyn crate::compression_engine::CompressionEngine>, Vec<Box<dyn crate::meta_plugin::MetaPlugin>>) {
let digest_type = cmd_args_digest_type(cmd, &args); let digest_type = cmd_args_digest_type(cmd, &args);
debug!("MAIN: Digest type: {:?}", digest_type); debug!("MAIN: Digest type: {:?}", digest_type);
let compression_type = cmd_args_compression_type(cmd, &args); let compression_type = cmd_args_compression_type(cmd, &args);
debug!("MAIN: Compression type: {:?}", compression_type); debug!("MAIN: Compression type: {:?}", compression_type);
let compression_engine = let compression_engine =
get_compression_engine(compression_type.clone()).expect("Unable to get compression engine"); crate::compression_engine::get_compression_engine(compression_type.clone()).expect("Unable to get compression engine");
// Start with meta plugin types from command line // Start with meta plugin types from command line
let mut meta_plugin_types: Vec<MetaPluginType> = cmd_args_meta_plugin_types(cmd, &args); let mut meta_plugin_types: Vec<crate::meta_plugin::MetaPluginType> = cmd_args_meta_plugin_types(cmd, &args);
debug!("MAIN: Meta plugin types: {:?}", meta_plugin_types); debug!("MAIN: Meta plugin types: {:?}", meta_plugin_types);
// Convert digest type to meta plugin type and add to the list if needed // Convert digest type to meta plugin type and add to the list if needed
let digest_meta_plugin_type = match digest_type { let digest_meta_plugin_type = match digest_type {
crate::meta_plugin::MetaPluginType::DigestSha256 => Some(MetaPluginType::DigestSha256), crate::meta_plugin::MetaPluginType::DigestSha256 => Some(crate::meta_plugin::MetaPluginType::DigestSha256),
crate::meta_plugin::MetaPluginType::DigestMd5 => Some(MetaPluginType::DigestMd5), crate::meta_plugin::MetaPluginType::DigestMd5 => Some(crate::meta_plugin::MetaPluginType::DigestMd5),
_ => None, _ => None,
}; };
@@ -63,9 +53,9 @@ pub fn mode_save(
} }
// Initialize meta_plugins with MetaPlugin instances for each MetaPluginType // Initialize meta_plugins with MetaPlugin instances for each MetaPluginType
let mut meta_plugins: Vec<Box<dyn MetaPlugin>> = meta_plugin_types let mut meta_plugins: Vec<Box<dyn crate::meta_plugin::MetaPlugin>> = meta_plugin_types
.iter() .iter()
.map(|meta_plugin_type| get_meta_plugin(meta_plugin_type.clone())) .map(|meta_plugin_type| crate::meta_plugin::get_meta_plugin(meta_plugin_type.clone()))
.collect(); .collect();
// Check for unsupported meta plugins, warn the user, and remove them from the list // Check for unsupported meta plugins, warn the user, and remove them from the list
@@ -76,21 +66,30 @@ pub fn mode_save(
// We need to get the meta name for the warning message // We need to get the meta name for the warning message
// Since we can't mutably borrow meta_plugin here, we create a temporary one // Since we can't mutably borrow meta_plugin here, we create a temporary one
let meta_plugin_type = meta_plugin_types[i].clone(); let meta_plugin_type = meta_plugin_types[i].clone();
let mut temp_plugin = get_meta_plugin(meta_plugin_type); let mut temp_plugin = crate::meta_plugin::get_meta_plugin(meta_plugin_type);
eprintln!("Warning: Meta plugin '{}' is enabled but not supported on this system", temp_plugin.meta_name()); eprintln!("Warning: Meta plugin '{}' is enabled but not supported on this system", temp_plugin.meta_name());
} }
i += 1; i += 1;
is_supported is_supported
}); });
let mut item = db::Item { (compression_type, compression_engine, meta_plugins)
}
fn create_and_log_item(
conn: &mut rusqlite::Connection,
args: &crate::Args,
tags: &Vec<String>,
compression_type: &crate::compression_engine::CompressionType,
) -> Result<crate::db::Item, anyhow::Error> {
let mut item = crate::db::Item {
id: None, id: None,
ts: Utc::now(), ts: chrono::Utc::now(),
size: None, size: None,
compression: compression_type.to_string(), compression: compression_type.to_string(),
}; };
let id = db::insert_item(conn, item.clone())?; let id = crate::db::insert_item(conn, item.clone())?;
item.id = Some(id); item.id = Some(id);
debug!("MAIN: Added item {:?}", item.clone()); debug!("MAIN: Added item {:?}", item.clone());
@@ -117,11 +116,23 @@ pub fn mode_save(
} }
} }
db::set_item_tags(conn, item.clone(), tags)?; Ok(item)
}
let mut item_meta: HashMap<String, String> = get_meta_from_env(); fn setup_item_metadata(
conn: &mut rusqlite::Connection,
_args: &crate::Args,
item: &crate::db::Item,
tags: &Vec<String>,
) -> Result<(), anyhow::Error> {
crate::db::set_item_tags(conn, item.clone(), tags)?;
Ok(())
}
if let Ok(hostname) = gethostname().into_string() { fn collect_item_meta(args: &crate::Args) -> std::collections::HashMap<String, String> {
let mut item_meta: std::collections::HashMap<String, String> = crate::modes::common::get_meta_from_env();
if let Ok(hostname) = gethostname::gethostname().into_string() {
if !item_meta.contains_key("hostname") { if !item_meta.contains_key("hostname") {
item_meta.insert("hostname".to_string(), hostname); item_meta.insert("hostname".to_string(), hostname);
} }
@@ -132,30 +143,33 @@ pub fn mode_save(
item_meta.insert(item.key, item.value); item_meta.insert(item.key, item.value);
} }
for kv in item_meta.iter() { item_meta
let meta = db::Meta { }
id: item.id.unwrap(),
name: kv.0.to_string(), fn process_input_stream(
value: kv.1.to_string(), compression_engine: &Box<dyn crate::compression_engine::CompressionEngine>,
}; data_path: &std::path::PathBuf,
db::store_meta(conn, meta)?; item_id: i64,
} meta_plugins: &mut Vec<Box<dyn crate::meta_plugin::MetaPlugin>>,
) -> Result<(Box<dyn std::io::Write>, crate::db::Item), anyhow::Error> {
let mut item = crate::db::Item {
id: Some(item_id),
ts: chrono::Utc::now(),
size: None,
compression: String::new(), // Will be set later
};
let mut item_path = data_path.clone(); let mut item_path = data_path.clone();
item_path.push(id.to_string()); item_path.push(item_id.to_string());
let mut stdin = io::stdin().lock(); let mut stdin = std::io::stdin().lock();
let mut stdout = io::stdout().lock(); let mut stdout = std::io::stdout().lock();
let mut buffer = [0; libc::BUFSIZ as usize]; let mut buffer = [0; libc::BUFSIZ as usize];
let mut item_out: Box<dyn Write> = let mut item_out: Box<dyn std::io::Write> =
compression_engine compression_engine
.create(item_path.clone()) .create(item_path.clone())
.context(anyhow!( .map_err(|e| anyhow!("Unable to write file {:?}: {}", item_path, e))?;
"Unable to write file {:?} using compression {:?}",
item_path,
compression_type
))?;
debug!("MAIN: Starting IO loop"); debug!("MAIN: Starting IO loop");
loop { loop {
@@ -184,12 +198,25 @@ pub fn mode_save(
stdout.flush()?; stdout.flush()?;
item_out.flush()?; item_out.flush()?;
Ok((item_out, item))
}
fn finalize_meta_plugins(
conn: &rusqlite::Connection,
meta_plugins: &mut Vec<Box<dyn crate::meta_plugin::MetaPlugin>>,
item: &crate::db::Item,
) -> Result<(), anyhow::Error> {
for meta_plugin in meta_plugins.iter_mut() { for meta_plugin in meta_plugins.iter_mut() {
let meta_name = meta_plugin.meta_name(); let meta_name = meta_plugin.meta_name();
match meta_plugin.finalize() { match meta_plugin.finalize() {
Ok(meta_value) => { Ok(meta_value) => {
if let Err(e) = store_item_meta_value(conn, item.clone(), meta_name.clone(), meta_value) { let meta = crate::db::Meta {
id: item.id.ok_or_else(|| anyhow!("Item missing ID"))?,
name: meta_name.clone(),
value: meta_value,
};
if let Err(e) = crate::db::store_meta(conn, meta) {
eprintln!("Warning: Failed to store meta value for {}: {}", meta_name, e); eprintln!("Warning: Failed to store meta value for {}: {}", meta_name, e);
} }
} }
@@ -198,8 +225,51 @@ pub fn mode_save(
} }
} }
} }
Ok(())
}
db::update_item(conn, item.clone())?; pub fn mode_save(
cmd: &mut Command,
args: &crate::Args,
ids: &mut Vec<i64>,
tags: &mut Vec<String>,
conn: &mut rusqlite::Connection,
data_path: std::path::PathBuf,
) -> Result<(), anyhow::Error> {
validate_save_args(cmd, ids);
initialize_tags(tags);
let (compression_type, compression_engine, mut meta_plugins) = setup_compression_and_plugins(cmd, args);
let mut item = create_and_log_item(conn, args, tags, &compression_type)?;
setup_item_metadata(conn, args, &item, tags)?; // Pass mutable reference
// Save as much as possible in case something breaks - don't use transactions
// This allows partial saves to succeed even if some metadata operations fail
let item_meta = collect_item_meta(args);
let item_id = item.id.ok_or_else(|| anyhow!("Item missing ID"))?;
for kv in item_meta.iter() {
let meta = crate::db::Meta {
id: item_id,
name: kv.0.to_string(),
value: kv.1.to_string(),
};
crate::db::store_meta(conn, meta)?;
}
let (_item_out, processed_item) = process_input_stream(
&compression_engine,
&data_path,
item_id,
&mut meta_plugins,
)?;
item.size = processed_item.size;
item.compression = compression_type.to_string();
finalize_meta_plugins(conn, &mut meta_plugins, &item)?;
crate::db::update_item(conn, item.clone())?;
Ok(()) Ok(())
} }

101
src/modes/server.rs Normal file
View File

@@ -0,0 +1,101 @@
use anyhow::Result;
use axum::{
routing::get,
Router,
};
use clap::Command;
use log::{debug, info, warn};
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use tower_http::cors::CorsLayer;
use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;
use crate::Args;
mod common;
mod status;
mod items;
mod content;
mod docs;
pub use common::{ServerConfig, AppState, logging_middleware};
use status::handle_status;
use items::{handle_list_items, handle_get_item, handle_put_item, handle_delete_item};
use content::{handle_get_content_latest, handle_get_content};
use docs::{handle_openapi, handle_swagger_ui};
pub fn mode_server(
_cmd: &mut Command,
args: &Args,
conn: &mut rusqlite::Connection,
data_path: PathBuf,
) -> Result<()> {
let server_address = args.mode.server.as_ref().unwrap();
let config = ServerConfig {
address: server_address.clone(),
password: args.options.server_password.clone(),
};
// We need to move the connection into the async runtime
let rt = tokio::runtime::Runtime::new()?;
// Take ownership of the connection and move it into the async runtime
let owned_conn = std::mem::replace(conn, rusqlite::Connection::open_in_memory()?);
rt.block_on(run_server(config, owned_conn, data_path, args))
}
async fn run_server(
config: ServerConfig,
conn: rusqlite::Connection,
data_dir: PathBuf,
args: &Args,
) -> Result<()> {
debug!("Starting REST HTTP server on {}", config.address);
// Use the existing database connection
let db_conn = Arc::new(Mutex::new(conn));
let state = AppState {
db: db_conn,
data_dir: data_dir.clone(),
password: config.password.clone(),
args: Arc::new(args.clone()),
};
let app = Router::new()
.route("/status", get(handle_status))
.route("/item/", get(handle_list_items).put(handle_put_item))
.route("/item/:id", get(handle_get_item).delete(handle_delete_item))
.route("/content", get(handle_get_content_latest))
.route("/content/:id", get(handle_get_content))
.route("/openapi.json", get(handle_openapi))
.route("/swagger/", get(handle_swagger_ui))
.layer(axum::middleware::from_fn(logging_middleware))
.layer(
ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
)
.with_state(state);
let addr: SocketAddr = if config.address.starts_with('/') || config.address.starts_with("./") {
// Unix socket - not supported by axum directly, fall back to TCP
warn!("Unix sockets not yet implemented, falling back to TCP on 127.0.0.1:8080");
"127.0.0.1:8080".parse()?
} else {
config.address.parse()?
};
info!("SERVER: HTTP server listening on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>()
).await?;
Ok(())
}

View File

@@ -0,0 +1,94 @@
use anyhow::Result;
use axum::http::HeaderMap;
use log::info;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::Mutex;
use crate::Args;
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub address: String,
pub password: Option<String>,
}
impl FromStr for ServerConfig {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(ServerConfig {
address: s.to_string(),
password: None,
})
}
}
#[derive(Clone)]
pub struct AppState {
pub db: Arc<Mutex<rusqlite::Connection>>,
pub data_dir: PathBuf,
pub password: Option<String>,
pub args: Arc<Args>,
}
#[derive(Serialize, Deserialize)]
pub struct ApiResponse<T> {
pub success: bool,
pub data: Option<T>,
pub error: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct ItemInfo {
pub id: i64,
pub ts: String,
pub size: Option<i64>,
pub compression: String,
pub tags: Vec<String>,
pub metadata: HashMap<String, String>,
}
#[derive(Debug, Deserialize)]
pub struct TagsQuery {
pub tags: Option<String>,
}
pub fn check_auth(headers: &HeaderMap, password: &Option<String>) -> bool {
if let Some(expected_password) = password {
if let Some(auth_header) = headers.get("authorization") {
if let Ok(auth_str) = auth_header.to_str() {
return auth_str.starts_with("Bearer ") && &auth_str[7..] == expected_password;
}
}
false
} else {
true // No password required
}
}
// Custom middleware for logging requests and responses
pub async fn logging_middleware(
req: axum::http::Request<axum::body::Body>,
next: axum::middleware::Next,
) -> Result<axum::http::Response<axum::body::Body>, axum::response::Response> {
let method = req.method().clone();
let uri = req.uri().clone();
let headers = req.headers().clone();
// Log incoming request
info!("SERVER: {} {} - Headers: {:?}", method, uri, headers);
let start = Instant::now();
let response = next.run(req).await;
let duration = start.elapsed();
// Log response
info!("SERVER: {} {} - Status: {} - Duration: {:?}", method, uri, response.status(), duration);
Ok(response)
}

181
src/modes/server/content.rs Normal file
View File

@@ -0,0 +1,181 @@
use anyhow::{Result, anyhow};
use axum::{
extract::{ConnectInfo, Path, Query, State},
http::{HeaderMap, StatusCode},
response::Json,
};
use log::warn;
use serde_json::json;
use std::collections::HashMap;
use std::io::Read;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::str::FromStr;
use crate::compression_engine::{CompressionType, get_compression_engine};
use crate::db;
use super::common::{AppState, ApiResponse, TagsQuery, check_auth};
pub async fn handle_get_content_latest(
State(state): State<AppState>,
Query(params): Query<TagsQuery>,
headers: HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> Result<Json<ApiResponse<String>>, StatusCode> {
if !check_auth(&headers, &state.password) {
warn!("Unauthorized request to /content from {}", addr);
return Err(StatusCode::UNAUTHORIZED);
}
let mut conn = state.db.lock().await;
let item = if let Some(tags_str) = params.tags {
let tags: Vec<String> = tags_str.split(',').map(|t| t.trim().to_string()).collect();
db::get_item_matching(&mut *conn, &tags, &HashMap::new())
.map_err(|e| {
warn!("Failed to get item matching tags {:?} for content: {}", tags, e);
StatusCode::INTERNAL_SERVER_ERROR
})?
} else {
db::get_item_last(&mut *conn).map_err(|e| {
warn!("Failed to get last item for content: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?
};
if let Some(item) = item {
match get_item_content(&item, &state.data_dir).await {
Ok(content) => {
let response = ApiResponse {
success: true,
data: Some(content),
error: None,
};
Ok(Json(response))
}
Err(e) => {
warn!("Failed to get content for item {}: {}", item.id.unwrap_or(0), e);
let response = ApiResponse::<String> {
success: false,
data: None,
error: Some(format!("Failed to retrieve content: {}", e)),
};
Ok(Json(response))
}
}
} else {
Err(StatusCode::NOT_FOUND)
}
}
pub async fn handle_get_content(
State(state): State<AppState>,
Path(item_id): Path<String>,
headers: HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> Result<Json<ApiResponse<String>>, StatusCode> {
if !check_auth(&headers, &state.password) {
warn!("Unauthorized request to /content/{} from {}", item_id, addr);
return Err(StatusCode::UNAUTHORIZED);
}
if let Ok(id) = item_id.parse::<i64>() {
// Validate that item ID is positive to prevent path traversal issues
if id <= 0 {
warn!("Invalid item ID {} from {}", id, addr);
return Err(StatusCode::BAD_REQUEST);
}
let mut conn = state.db.lock().await;
if let Some(item) = db::get_item(&mut *conn, id).map_err(|e| {
warn!("Failed to get item {} for content: {}", id, e);
StatusCode::INTERNAL_SERVER_ERROR
})? {
match get_item_content(&item, &state.data_dir).await {
Ok(content) => {
let response = ApiResponse {
success: true,
data: Some(content),
error: None,
};
Ok(Json(response))
}
Err(e) => {
warn!("Failed to get content for item {}: {}", id, e);
let response = ApiResponse::<String> {
success: false,
data: None,
error: Some(format!("Failed to retrieve content: {}", e)),
};
Ok(Json(response))
}
}
} else {
Err(StatusCode::NOT_FOUND)
}
} else {
Err(StatusCode::BAD_REQUEST)
}
}
async fn get_item_content(item: &db::Item, data_dir: &PathBuf) -> Result<String> {
let item_id = item.id.ok_or_else(|| anyhow!("Item missing ID"))?;
// Validate that item ID is positive to prevent path traversal issues
if item_id <= 0 {
return Err(anyhow!("Invalid item ID: {}", item_id));
}
let mut item_path = data_dir.clone();
item_path.push(item_id.to_string());
let compression_type = CompressionType::from_str(&item.compression)?;
let compression_engine = get_compression_engine(compression_type)?;
// Read the content using the compression engine
let mut reader = compression_engine.open(item_path)?;
let mut content = String::new();
reader.read_to_string(&mut content)?;
Ok(content)
}
pub fn get_content_openapi_spec() -> serde_json::Value {
json!({
"/content": {
"get": {
"summary": "Get content of latest item",
"parameters": [
{
"name": "tags",
"in": "query",
"schema": {"type": "string"},
"description": "Comma-separated list of tags to filter by"
}
],
"responses": {
"200": {"description": "Item content"},
"404": {"description": "No items found"}
}
}
},
"/content/{id}": {
"get": {
"summary": "Get content by item ID",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {"type": "integer"}
}
],
"responses": {
"200": {"description": "Item content"},
"404": {"description": "Item not found"}
}
}
}
})
}

110
src/modes/server/docs.rs Normal file
View File

@@ -0,0 +1,110 @@
use axum::response::{Html, Json};
use serde_json::json;
use super::status::get_status_openapi_spec;
use super::items::get_items_openapi_spec;
use super::content::get_content_openapi_spec;
pub async fn handle_openapi() -> Json<serde_json::Value> {
let mut paths = json!({});
// Merge all endpoint specifications
let status_paths = get_status_openapi_spec();
let items_paths = get_items_openapi_spec();
let content_paths = get_content_openapi_spec();
// Merge the path objects
if let serde_json::Value::Object(ref mut paths_map) = paths {
if let serde_json::Value::Object(status_map) = status_paths {
for (key, value) in status_map {
paths_map.insert(key, value);
}
}
if let serde_json::Value::Object(items_map) = items_paths {
for (key, value) in items_map {
paths_map.insert(key, value);
}
}
if let serde_json::Value::Object(content_map) = content_paths {
for (key, value) in content_map {
paths_map.insert(key, value);
}
}
}
let openapi_spec = json!({
"openapi": "3.0.0",
"info": {
"title": "Keep API",
"version": "1.0.0",
"description": "REST API for the Keep data storage system"
},
"servers": [
{
"url": "/",
"description": "Local server"
}
],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer"
}
},
"schemas": {
"ItemInfo": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"ts": {"type": "string", "format": "date-time"},
"size": {"type": "integer", "nullable": true},
"compression": {"type": "string"},
"tags": {"type": "array", "items": {"type": "string"}},
"metadata": {"type": "object"}
}
},
"StatusInfo": {
"type": "object",
"properties": {
"version": {"type": "string"},
"database_path": {"type": "string"},
"data_directory": {"type": "string"},
"compression_engines": {"type": "array", "items": {"type": "string"}},
"meta_plugins": {"type": "array", "items": {"type": "string"}}
}
}
}
},
"security": [{"bearerAuth": []}],
"paths": paths
});
Json(openapi_spec)
}
pub async fn handle_swagger_ui() -> Html<&'static str> {
let html = r#"<!DOCTYPE html>
<html>
<head>
<title>Keep API Documentation</title>
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@3.52.5/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@3.52.5/swagger-ui-bundle.js"></script>
<script>
SwaggerUIBundle({
url: '/openapi.json',
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.presets.standalone
]
});
</script>
</body>
</html>"#;
Html(html)
}

311
src/modes/server/items.rs Normal file
View File

@@ -0,0 +1,311 @@
use axum::{
extract::{ConnectInfo, Path, Query, State},
http::{HeaderMap, StatusCode},
response::Json,
};
use log::warn;
use serde_json::json;
use std::collections::HashMap;
use std::net::SocketAddr;
use crate::db;
use super::common::{AppState, ApiResponse, ItemInfo, TagsQuery, check_auth};
pub async fn handle_list_items(
State(state): State<AppState>,
Query(params): Query<TagsQuery>,
headers: HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> Result<Json<ApiResponse<Vec<ItemInfo>>>, StatusCode> {
if !check_auth(&headers, &state.password) {
warn!("Unauthorized request to /item/ from {}", addr);
return Err(StatusCode::UNAUTHORIZED);
}
let mut conn = state.db.lock().await;
let tags: Vec<String> = params.tags
.map(|s| s.split(',').map(|t| t.trim().to_string()).collect())
.unwrap_or_default();
let items = if tags.is_empty() {
db::get_items(&mut *conn).map_err(|e| {
warn!("Failed to get items: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?
} else {
db::get_items_matching(&mut *conn, &tags, &HashMap::new())
.map_err(|e| {
warn!("Failed to get items matching tags {:?}: {}", tags, e);
StatusCode::INTERNAL_SERVER_ERROR
})?
};
// Get item IDs for batch queries
let item_ids: Vec<i64> = items.iter().filter_map(|item| item.id).collect();
// Get tags and metadata for all items
let tags_map = db::get_tags_for_items(&mut *conn, &item_ids)
.map_err(|e| {
warn!("Failed to get tags for items: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let meta_map = db::get_meta_for_items(&mut *conn, &item_ids)
.map_err(|e| {
warn!("Failed to get metadata for items: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let item_infos: Vec<ItemInfo> = items
.into_iter()
.map(|item| {
let item_id = item.id.unwrap_or(0);
let item_tags = tags_map.get(&item_id)
.map(|tags| tags.iter().map(|t| t.name.clone()).collect())
.unwrap_or_default();
let item_meta = meta_map.get(&item_id)
.cloned()
.unwrap_or_default();
ItemInfo {
id: item_id,
ts: item.ts.to_rfc3339(),
size: item.size,
compression: item.compression,
tags: item_tags,
metadata: item_meta,
}
})
.collect();
let response = ApiResponse {
success: true,
data: Some(item_infos),
error: None,
};
Ok(Json(response))
}
pub async fn handle_get_item(
State(state): State<AppState>,
Path(item_id): Path<String>,
Query(params): Query<TagsQuery>,
headers: HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> Result<Json<ApiResponse<ItemInfo>>, StatusCode> {
if !check_auth(&headers, &state.password) {
warn!("Unauthorized request to /item/{} from {}", item_id, addr);
return Err(StatusCode::UNAUTHORIZED);
}
let mut conn = state.db.lock().await;
let item = if let Ok(id) = item_id.parse::<i64>() {
db::get_item(&mut *conn, id).map_err(|e| {
warn!("Failed to get item {}: {}", id, e);
StatusCode::INTERNAL_SERVER_ERROR
})?
} else {
// Try to find by tags
if let Some(tags_str) = params.tags {
let tags: Vec<String> = tags_str.split(',').map(|t| t.trim().to_string()).collect();
db::get_item_matching(&mut *conn, &tags, &HashMap::new())
.map_err(|e| {
warn!("Failed to get item matching tags {:?}: {}", tags, e);
StatusCode::INTERNAL_SERVER_ERROR
})?
} else {
warn!("Invalid item ID '{}' and no tags provided", item_id);
return Err(StatusCode::BAD_REQUEST);
}
};
if let Some(item) = item {
let item_tags = db::get_item_tags(&mut *conn, &item)
.map_err(|e| {
warn!("Failed to get tags for item {}: {}", item.id.unwrap_or(0), e);
StatusCode::INTERNAL_SERVER_ERROR
})?
.into_iter()
.map(|t| t.name)
.collect();
let item_meta = db::get_item_meta(&mut *conn, &item)
.map_err(|e| {
warn!("Failed to get metadata for item {}: {}", item.id.unwrap_or(0), e);
StatusCode::INTERNAL_SERVER_ERROR
})?
.into_iter()
.map(|m| (m.name, m.value))
.collect();
let item_info = ItemInfo {
id: item.id.unwrap_or(0),
ts: item.ts.to_rfc3339(),
size: item.size,
compression: item.compression,
tags: item_tags,
metadata: item_meta,
};
let response = ApiResponse {
success: true,
data: Some(item_info),
error: None,
};
Ok(Json(response))
} else {
Err(StatusCode::NOT_FOUND)
}
}
pub async fn handle_put_item(
State(state): State<AppState>,
headers: HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> Result<Json<ApiResponse<ItemInfo>>, StatusCode> {
if !check_auth(&headers, &state.password) {
warn!("Unauthorized request to PUT /item/ from {}", addr);
return Err(StatusCode::UNAUTHORIZED);
}
// This is a simplified implementation
// In a real implementation, you'd need to properly parse multipart/form-data
// or JSON payload with the item data
let response = ApiResponse::<ItemInfo> {
success: false,
data: None,
error: Some("PUT /item/ not yet implemented".to_string()),
};
Ok(Json(response))
}
pub async fn handle_delete_item(
State(state): State<AppState>,
Path(item_id): Path<String>,
headers: HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> Result<Json<ApiResponse<()>>, StatusCode> {
if !check_auth(&headers, &state.password) {
warn!("Unauthorized request to DELETE /item/{} from {}", item_id, addr);
return Err(StatusCode::UNAUTHORIZED);
}
if let Ok(id) = item_id.parse::<i64>() {
let mut conn = state.db.lock().await;
if let Some(item) = db::get_item(&mut *conn, id).map_err(|e| {
warn!("Failed to get item {} for deletion: {}", id, e);
StatusCode::INTERNAL_SERVER_ERROR
})? {
db::delete_item(&mut *conn, item).map_err(|e| {
warn!("Failed to delete item {}: {}", id, e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let response = ApiResponse::<()> {
success: true,
data: None,
error: None,
};
Ok(Json(response))
} else {
Err(StatusCode::NOT_FOUND)
}
} else {
Err(StatusCode::BAD_REQUEST)
}
}
pub fn get_items_openapi_spec() -> serde_json::Value {
json!({
"/item/": {
"get": {
"summary": "List items",
"parameters": [
{
"name": "tags",
"in": "query",
"schema": {"type": "string"},
"description": "Comma-separated list of tags to filter by"
}
],
"responses": {
"200": {
"description": "List of items",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {"$ref": "#/components/schemas/ItemInfo"}
}
}
}
}
}
},
"put": {
"summary": "Add new item",
"responses": {
"201": {
"description": "Item created",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/ItemInfo"}
}
}
}
}
}
},
"/item/{id}": {
"get": {
"summary": "Get item by ID",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {"type": "string"},
"description": "Item ID or use tags query parameter"
},
{
"name": "tags",
"in": "query",
"schema": {"type": "string"},
"description": "Comma-separated list of tags (when ID is not numeric)"
}
],
"responses": {
"200": {
"description": "Item information",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/ItemInfo"}
}
}
},
"404": {"description": "Item not found"}
}
},
"delete": {
"summary": "Delete item by ID",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {"type": "integer"}
}
],
"responses": {
"200": {"description": "Item deleted"},
"404": {"description": "Item not found"}
}
}
}
})
}

View File

@@ -0,0 +1,77 @@
use axum::{
extract::{ConnectInfo, State},
http::{HeaderMap, StatusCode},
response::Json,
};
use clap::Command;
use log::warn;
use serde_json::json;
use std::net::SocketAddr;
use crate::meta_plugin::MetaPluginType;
use crate::modes::status::{StatusInfo, generate_status_info};
use super::common::{AppState, ApiResponse, check_auth};
pub async fn handle_status(
State(state): State<AppState>,
headers: HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> Result<Json<ApiResponse<StatusInfo>>, StatusCode> {
if !check_auth(&headers, &state.password) {
warn!("Unauthorized request from {}", addr);
return Err(StatusCode::UNAUTHORIZED);
}
// Use the actual args that the server was started with
let args = &state.args;
// Determine which meta plugins would be enabled for a save operation
let mut meta_plugin_types: Vec<MetaPluginType> = crate::modes::common::cmd_args_meta_plugin_types(&mut Command::new("keep"), args);
// Add digest type if specified
let digest_type = crate::modes::common::cmd_args_digest_type(&mut Command::new("keep"), args);
let digest_meta_plugin_type = match digest_type {
crate::meta_plugin::MetaPluginType::DigestSha256 => Some(MetaPluginType::DigestSha256),
crate::meta_plugin::MetaPluginType::DigestMd5 => Some(MetaPluginType::DigestMd5),
_ => None,
};
if let Some(digest_plugin_type) = digest_meta_plugin_type {
if !meta_plugin_types.contains(&digest_plugin_type) {
meta_plugin_types.push(digest_plugin_type);
}
}
let mut db_path = state.data_dir.clone();
db_path.push("keep-1.db");
let status_info = generate_status_info(state.data_dir.clone(), db_path, &meta_plugin_types);
let response = ApiResponse {
success: true,
data: Some(status_info),
error: None,
};
Ok(Json(response))
}
pub fn get_status_openapi_spec() -> serde_json::Value {
json!({
"/status": {
"get": {
"summary": "Get system status",
"responses": {
"200": {
"description": "System status",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/StatusInfo"}
}
}
}
}
}
}
})
}

View File

@@ -8,8 +8,11 @@ use crate::compression_engine::COMPRESSION_PROGRAMS;
use crate::compression_engine::CompressionType; use crate::compression_engine::CompressionType;
use crate::compression_engine::program::CompressionEngineProgram; use crate::compression_engine::program::CompressionEngineProgram;
use crate::modes::common::get_format_box_chars_no_border_line_separator; use crate::modes::common::{get_format_box_chars_no_border_line_separator, get_output_format, OutputFormat};
use prettytable::color; use prettytable::color;
use serde::{Deserialize, Serialize};
use serde_json;
use serde_yaml;
use prettytable::row; use prettytable::row;
use prettytable::{Attr, Cell, Row, Table}; use prettytable::{Attr, Cell, Row, Table};
use prettytable::format::consts::FORMAT_NO_BORDER_LINE_SEPARATOR; use prettytable::format::consts::FORMAT_NO_BORDER_LINE_SEPARATOR;
@@ -17,7 +20,130 @@ use prettytable::format::consts::FORMAT_NO_BORDER_LINE_SEPARATOR;
use crate::meta_plugin; use crate::meta_plugin;
use crate::meta_plugin::MetaPluginType; use crate::meta_plugin::MetaPluginType;
fn build_path_table(data_path: PathBuf, db_path: PathBuf) -> Table { #[derive(Serialize, Deserialize)]
pub struct StatusInfo {
pub paths: PathInfo,
pub compression: Vec<CompressionInfo>,
pub meta_plugins: Vec<MetaPluginInfo>,
}
#[derive(Serialize, Deserialize)]
pub struct PathInfo {
pub data: String,
pub database: String,
}
#[derive(Serialize, Deserialize)]
pub struct CompressionInfo {
#[serde(rename = "type")]
pub compression_type: String,
pub found: bool,
pub default: bool,
pub binary: String,
pub compress: String,
pub decompress: String,
}
#[derive(Serialize, Deserialize)]
pub struct MetaPluginInfo {
pub meta_name: String,
pub found: bool,
pub enabled: bool,
pub binary: String,
pub args: String,
}
pub fn generate_status_info(
data_path: PathBuf,
db_path: PathBuf,
enabled_meta_plugins: &Vec<MetaPluginType>,
) -> StatusInfo {
let path_info = PathInfo {
data: data_path.into_os_string().into_string().expect("Unable to convert data path to string"),
database: db_path.into_os_string().into_string().expect("Unable to convert DB path to string"),
};
let default_type = compression_engine::default_compression_type();
let mut compression_info = Vec::new();
// Sort compression types by their string representation
let mut sorted_compression_types: Vec<CompressionType> = CompressionType::iter().collect();
sorted_compression_types.sort_by_key(|ct| ct.to_string());
for compression_type in sorted_compression_types {
let compression_program: CompressionEngineProgram =
match &COMPRESSION_PROGRAMS[compression_type.clone()] {
Some(compression_program) => compression_program.clone(),
None => CompressionEngineProgram {
program: "".to_string(),
compress: Vec::new(),
decompress: Vec::new(),
supported: true,
},
};
let is_default = compression_type == default_type;
let binary = if compression_program.program.is_empty() {
"<INTERNAL>".to_string()
} else {
compression_program.program
};
compression_info.push(CompressionInfo {
compression_type: compression_type.to_string(),
found: compression_program.supported,
default: is_default,
binary,
compress: compression_program.compress.join(" "),
decompress: compression_program.decompress.join(" "),
});
}
let mut meta_plugin_info = Vec::new();
// Sort meta plugin types by their meta name
let mut sorted_meta_plugins: Vec<MetaPluginType> = MetaPluginType::iter().collect();
sorted_meta_plugins.sort_by_key(|meta_plugin_type| {
let mut meta_plugin = meta_plugin::get_meta_plugin(meta_plugin_type.clone());
meta_plugin.meta_name()
});
for meta_plugin_type in sorted_meta_plugins {
let mut meta_plugin = meta_plugin::get_meta_plugin(meta_plugin_type.clone());
let is_supported = meta_plugin.is_supported();
let is_enabled = enabled_meta_plugins.contains(&meta_plugin_type);
let (binary_display, args_display) = if !is_supported {
("<NOT FOUND>".to_string(), "".to_string())
} else {
if meta_plugin.is_internal() {
("<INTERNAL>".to_string(), "".to_string())
} else {
if let Some((program, args)) = meta_plugin.program_info() {
(program.to_string(), args.join(" "))
} else {
("<NOT FOUND>".to_string(), "".to_string())
}
}
};
meta_plugin_info.push(MetaPluginInfo {
meta_name: meta_plugin.meta_name(),
found: is_supported,
enabled: is_enabled,
binary: binary_display,
args: args_display,
});
}
StatusInfo {
paths: path_info,
compression: compression_info,
meta_plugins: meta_plugin_info,
}
}
fn build_path_table(path_info: &PathInfo) -> Table {
let mut path_table = Table::new(); let mut path_table = Table::new();
if std::io::stdout().is_terminal() { if std::io::stdout().is_terminal() {
@@ -33,28 +159,18 @@ fn build_path_table(data_path: PathBuf, db_path: PathBuf) -> Table {
path_table.add_row(Row::new(vec![ path_table.add_row(Row::new(vec![
Cell::new("Data"), Cell::new("Data"),
Cell::new( Cell::new(&path_info.data),
&data_path
.into_os_string()
.into_string()
.expect("Unable to convert data path to string"),
),
])); ]));
path_table.add_row(Row::new(vec![ path_table.add_row(Row::new(vec![
Cell::new("Database"), Cell::new("Database"),
Cell::new( Cell::new(&path_info.database),
&db_path
.into_os_string()
.into_string()
.expect("Unable to convert DB path to string"),
),
])); ]));
path_table path_table
} }
fn build_compression_table() -> Table { fn build_compression_table(compression_info: &Vec<CompressionInfo>) -> Table {
let mut compression_table = Table::new(); let mut compression_table = Table::new();
if std::io::stdout().is_terminal() { if std::io::stdout().is_terminal() {
compression_table.set_format(get_format_box_chars_no_border_line_separator()); compression_table.set_format(get_format_box_chars_no_border_line_separator());
@@ -70,48 +186,30 @@ fn build_compression_table() -> Table {
b->"Compress", b->"Compress",
b->"Decompress")); b->"Decompress"));
let default_type = compression_engine::default_compression_type(); for info in compression_info {
for compression_type in CompressionType::iter() {
let compression_program: CompressionEngineProgram =
match &COMPRESSION_PROGRAMS[compression_type.clone()] {
Some(compression_program) => compression_program.clone(),
None => CompressionEngineProgram {
program: "".to_string(),
compress: Vec::new(),
decompress: Vec::new(),
supported: true,
},
};
let is_default = compression_type == default_type;
compression_table.add_row(Row::new(vec![ compression_table.add_row(Row::new(vec![
Cell::new(&compression_type.to_string()), Cell::new(&info.compression_type),
match compression_program.supported { match info.found {
true => Cell::new("Yes").with_style(Attr::ForegroundColor(color::GREEN)), true => Cell::new("Yes").with_style(Attr::ForegroundColor(color::GREEN)),
false => Cell::new("No").with_style(Attr::ForegroundColor(color::RED)), false => Cell::new("No").with_style(Attr::ForegroundColor(color::RED)),
}, },
match is_default { match info.default {
true => Cell::new("Yes").with_style(Attr::ForegroundColor(color::GREEN)), true => Cell::new("Yes").with_style(Attr::ForegroundColor(color::GREEN)),
false => Cell::new("No"), false => Cell::new("No"),
}, },
match compression_program.program.is_empty() { match info.binary.as_str() {
true => { "<INTERNAL>" => Cell::new(&info.binary).with_style(Attr::ForegroundColor(color::BRIGHT_BLACK)),
Cell::new("<INTERNAL>").with_style(Attr::ForegroundColor(color::BRIGHT_BLACK)) _ => Cell::new(&info.binary),
}
false => Cell::new(&compression_program.program),
}, },
Cell::new(&compression_program.compress.join(" ")), Cell::new(&info.compress),
Cell::new(&compression_program.decompress.join(" ")), Cell::new(&info.decompress),
])); ]));
} }
compression_table compression_table
} }
fn build_meta_plugin_table(meta_plugin_info: &Vec<MetaPluginInfo>) -> Table {
fn build_meta_plugin_table(enabled_meta_plugins: &Vec<MetaPluginType>) -> Table {
let mut meta_plugin_table = Table::new(); let mut meta_plugin_table = Table::new();
if std::io::stdout().is_terminal() { if std::io::stdout().is_terminal() {
meta_plugin_table.set_format(get_format_box_chars_no_border_line_separator()); meta_plugin_table.set_format(get_format_box_chars_no_border_line_separator());
@@ -126,52 +224,23 @@ fn build_meta_plugin_table(enabled_meta_plugins: &Vec<MetaPluginType>) -> Table
b->"Binary", b->"Binary",
b->"Args")); b->"Args"));
for meta_plugin_type in MetaPluginType::iter() { for info in meta_plugin_info {
let mut meta_plugin = meta_plugin::get_meta_plugin(meta_plugin_type.clone());
let is_supported = meta_plugin.is_supported();
let is_enabled = enabled_meta_plugins.contains(&meta_plugin_type);
// Determine what implementation will actually be used
let (binary_display, args_display) = if !is_supported {
("<NOT FOUND>".to_string(), "".to_string())
} else {
match meta_plugin_type {
// For internal plugins, always show as internal
MetaPluginType::DigestSha256 | MetaPluginType::ReadTime | MetaPluginType::ReadRate |
MetaPluginType::Cwd | MetaPluginType::Uid | MetaPluginType::User |
MetaPluginType::Gid | MetaPluginType::Group | MetaPluginType::Shell |
MetaPluginType::ShellPid | MetaPluginType::KeepPid | MetaPluginType::Hostname |
MetaPluginType::FullHostname => {
("<INTERNAL>".to_string(), "".to_string())
},
// For program-based plugins, show program info
_ => {
// Get program info from the meta plugin itself
if let Some((program, args)) = meta_plugin.program_info() {
(program.to_string(), args.join(" "))
} else {
("<NOT FOUND>".to_string(), "".to_string())
}
}
}
};
meta_plugin_table.add_row(Row::new(vec![ meta_plugin_table.add_row(Row::new(vec![
Cell::new(&meta_plugin.meta_name()), Cell::new(&info.meta_name),
match is_supported { match info.found {
true => Cell::new("Yes").with_style(Attr::ForegroundColor(color::GREEN)), true => Cell::new("Yes").with_style(Attr::ForegroundColor(color::GREEN)),
false => Cell::new("No").with_style(Attr::ForegroundColor(color::RED)), false => Cell::new("No").with_style(Attr::ForegroundColor(color::RED)),
}, },
match is_enabled { match info.enabled {
true => Cell::new("Yes").with_style(Attr::ForegroundColor(color::GREEN)), true => Cell::new("Yes").with_style(Attr::ForegroundColor(color::GREEN)),
false => Cell::new("No"), false => Cell::new("No"),
}, },
match binary_display.as_str() { match info.binary.as_str() {
"<INTERNAL>" => Cell::new(&binary_display).with_style(Attr::ForegroundColor(color::BRIGHT_BLACK)), "<INTERNAL>" => Cell::new(&info.binary).with_style(Attr::ForegroundColor(color::BRIGHT_BLACK)),
"<NOT FOUND>" => Cell::new(&binary_display).with_style(Attr::ForegroundColor(color::RED)), "<NOT FOUND>" => Cell::new(&info.binary).with_style(Attr::ForegroundColor(color::RED)),
_ => Cell::new(&binary_display), _ => Cell::new(&info.binary),
}, },
Cell::new(&args_display), Cell::new(&info.args),
])); ]));
} }
@@ -201,13 +270,28 @@ pub fn mode_status(
} }
} }
println!("PATHS:"); let output_format = get_output_format(args);
build_path_table(data_path, db_path).printstd(); let status_info = generate_status_info(data_path, db_path, &meta_plugin_types);
println!();
println!("COMPRESSION:"); match output_format {
build_compression_table().printstd(); OutputFormat::Table => {
println!(); println!("PATHS:");
println!("META PLUGINS:"); build_path_table(&status_info.paths).printstd();
build_meta_plugin_table(&meta_plugin_types).printstd(); println!();
Ok(()) println!("COMPRESSION:");
build_compression_table(&status_info.compression).printstd();
println!();
println!("META PLUGINS:");
build_meta_plugin_table(&status_info.meta_plugins).printstd();
Ok(())
},
OutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&status_info)?);
Ok(())
},
OutputFormat::Yaml => {
println!("{}", serde_yaml::to_string(&status_info)?);
Ok(())
}
}
} }

View File

@@ -1,11 +1,11 @@
use anyhow::Result; use anyhow::{anyhow, Result};
use std::path::PathBuf; use std::path::PathBuf;
use std::str::FromStr; use std::str::FromStr;
use crate::compression_engine::{CompressionType, get_compression_engine}; use crate::compression_engine::{CompressionType, get_compression_engine};
use crate::db; use crate::db;
use crate::meta_plugin; use crate::meta_plugin;
use crate::modes::common::{cmd_args_digest_type, get_digest_type_meta, store_item_digest_value}; use crate::modes::common::{cmd_args_digest_type, get_digest_type_meta};
use clap::Command; use clap::Command;
use clap::error::ErrorKind; use clap::error::ErrorKind;
use log::{debug, info}; use log::{debug, info};
@@ -35,14 +35,18 @@ pub fn mode_update(
let mut item = item_maybe.expect("Unable to find item in database"); let mut item = item_maybe.expect("Unable to find item in database");
debug!("MAIN: Found item {:?}", item); debug!("MAIN: Found item {:?}", item);
// Use a transaction for database operations to ensure atomicity
let tx = conn.transaction()?;
if !tags.is_empty() { if !tags.is_empty() {
debug!("MAIN: Updating item tags"); debug!("MAIN: Updating item tags");
db::set_item_tags(conn, item.clone(), tags)?; db::set_item_tags(&tx, item.clone(), tags)?;
} }
let item_id = item.id.ok_or_else(|| anyhow!("Item missing ID"))?;
let item_path = { let item_path = {
let mut path = data_path.clone(); let mut path = data_path.clone();
path.push(item.id.unwrap().to_string()); path.push(item_id.to_string());
path path
}; };
@@ -58,7 +62,7 @@ pub fn mode_update(
debug!("MAIN: Updating stream size of {:?}", item_path); debug!("MAIN: Updating stream size of {:?}", item_path);
let size = compression_engine.size(item_path.clone())?; let size = compression_engine.size(item_path.clone())?;
item.size = Some(size as i64); item.size = Some(size as i64);
db::update_item(conn, item.clone())?; db::update_item(&tx, item.clone())?;
} else { } else {
debug!( debug!(
"MAIN: Unable to update size of item due to missing file {:?}", "MAIN: Unable to update size of item due to missing file {:?}",
@@ -69,7 +73,7 @@ pub fn mode_update(
let digest_type = cmd_args_digest_type(cmd, args); let digest_type = cmd_args_digest_type(cmd, args);
let digest_meta = get_digest_type_meta(digest_type.clone()); let digest_meta = get_digest_type_meta(digest_type.clone());
let digest_value = db::get_item_meta_value(&conn, &item, digest_meta)?; let digest_value = db::get_item_meta_value(&tx, &item, digest_meta)?;
if digest_value.is_none() || digest_value.unwrap().is_empty() { if digest_value.is_none() || digest_value.unwrap().is_empty() {
let item_file_metadata = item_path.metadata(); let item_file_metadata = item_path.metadata();
@@ -95,8 +99,14 @@ pub fn mode_update(
let digest_value = digest_engine.finalize()?; let digest_value = digest_engine.finalize()?;
debug!("DIGEST: {}", digest_value); debug!("DIGEST: {}", digest_value);
// Save digest to meta using the common function // Save digest to meta
store_item_digest_value(conn, item.clone(), digest_type, digest_value)?; let digest_meta_name = get_digest_type_meta(digest_type);
let digest_meta = db::Meta {
id: item_id,
name: digest_meta_name,
value: digest_value,
};
db::store_meta(&tx, digest_meta)?;
} else { } else {
debug!( debug!(
"MAIN: Unable to update digest of item due to missing file {:?}", "MAIN: Unable to update digest of item due to missing file {:?}",
@@ -109,13 +119,16 @@ pub fn mode_update(
debug!("MAIN: Updating item meta"); debug!("MAIN: Updating item meta");
for kv in args.item.meta.iter() { for kv in args.item.meta.iter() {
let meta = db::Meta { let meta = db::Meta {
id: item.id.unwrap(), id: item_id,
name: kv.key.to_string(), name: kv.key.to_string(),
value: kv.value.to_string(), value: kv.value.to_string(),
}; };
db::store_meta(conn, meta)?; db::store_meta(&tx, meta)?;
} }
} }
// Commit the transaction
tx.commit()?;
Ok(()) Ok(())
} }