diff --git a/Cargo.lock b/Cargo.lock index 6bf32fec..39b742d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -248,6 +248,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + [[package]] name = "cc" version = "1.2.50" @@ -562,6 +568,29 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "enum-primitive-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba7795da175654fe16979af73f81f26a8ea27638d8d9823d317016888a63dc4c" +dependencies = [ + "num-traits", + "quote", + "syn 2.0.111", +] + [[package]] name = "env_filter" version = "0.1.4" @@ -802,6 +831,22 @@ dependencies = [ "winapi", ] +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "iana-time-zone" version = "0.1.64" @@ -856,6 +901,23 @@ dependencies = [ "libc", ] +[[package]] +name = "ipp" +version = "5.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ff00aa5989ec46fad3764aa77bd04b8ec357344d7eae4053a0df5f8e9660beb" +dependencies = [ + "base64", + "bytes", + "enum-as-inner", + "enum-primitive-derive", + "http", + "log", + "num-traits", + "thiserror 2.0.17", + "ureq", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1302,6 +1364,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pest" version = "2.8.4" @@ -1524,7 +1592,7 @@ dependencies = [ "libc", "plib", "termion", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -1593,7 +1661,7 @@ dependencies = [ "regex-lite", "similar-asserts", "test-log", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -1634,7 +1702,7 @@ dependencies = [ "regex", "rstest", "terminfo 0.9.0", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -1670,6 +1738,17 @@ dependencies = [ "tempfile", ] +[[package]] +name = "posixutils-print" +version = "0.1.0" +dependencies = [ + "clap", + "gettext-rs", + "ipp", + "num-traits", + "plib", +] + [[package]] name = "posixutils-process" version = "0.7.0" @@ -1743,7 +1822,7 @@ dependencies = [ "proptest", "rand 0.8.5", "regex", - "thiserror", + "thiserror 1.0.69", "topological-sort", "walkdir", ] @@ -1775,7 +1854,7 @@ dependencies = [ "libcrypt-rs", "plib", "syslog", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -2012,7 +2091,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -2460,7 +2539,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", ] [[package]] @@ -2474,6 +2562,17 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -2660,6 +2759,37 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "ureq" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a" +dependencies = [ + "base64", + "log", + "percent-encoding", + "ureq-proto", + "utf-8", +] + +[[package]] +name = "ureq-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8parse" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index ac2a6de5..7d1c9d48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ members = [ "pathnames", "pax", "plib", + "print", "process", "sccs", "screen", diff --git a/README.md b/README.md index 1bc049dd..267da57d 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,7 @@ Because it is a FAQ, the major differences between this project and uutils are: - [x] ipcs (IPC) - [x] kill - [x] logger + - [x] lp - [x] patch - [x] ps - [x] stty @@ -209,9 +210,6 @@ Because it is a FAQ, the major differences between this project and uutils are: - [ ] msgfmt (i18n) - [ ] ngettext (i18n) -### Misc. category - - [ ] lp - ## Installation These are "core" utilities of any operating system. Production packaging in the future will be done on a per-distro basis in a distro-specific way. diff --git a/lib/utils.tsv b/lib/utils.tsv index 3b078feb..3dbe4645 100644 --- a/lib/utils.tsv +++ b/lib/utils.tsv @@ -53,6 +53,7 @@ link tree/link.rs ln tree/ln.rs logger users/logger.rs logname users/logname.rs +lp print/lp.rs ls tree/ls.rs m4 m4/ mailx mailx/ diff --git a/print/Cargo.toml b/print/Cargo.toml new file mode 100644 index 00000000..1fd84813 --- /dev/null +++ b/print/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "posixutils-print" +version = "0.1.0" +authors = ["Jeff Garzik"] +repository.workspace = true +license.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] +plib = { path = "../plib" } +clap.workspace = true +gettext-rs.workspace = true +num-traits = "0.2" + +# IPP - blocking client only, no TLS for minimal deps +ipp = { version = "5.3", default-features = false, features = ["client"] } + +[lints] +workspace = true + +[[bin]] +name = "lp" +path = "./lp.rs" diff --git a/print/lp.rs b/print/lp.rs new file mode 100644 index 00000000..39faccb8 --- /dev/null +++ b/print/lp.rs @@ -0,0 +1,252 @@ +// +// Copyright (c) 2024 Jeff Garzik +// +// This file is part of the posixutils-rs project covered under +// the MIT License. For the full license text, please see the LICENSE +// file in the root directory of this project. +// SPDX-License-Identifier: MIT +// + +use std::env; +use std::fs::File; +use std::io::{self, Cursor, Read}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use clap::Parser; +use gettextrs::{bind_textdomain_codeset, gettext, setlocale, textdomain, LocaleCategory}; +use ipp::prelude::*; +use num_traits::ToPrimitive; + +/// lp - send files to a printer +#[derive(Parser)] +#[command(version, about = gettext("lp - send files to a printer"))] +struct Args { + /// Exit only after file access is no longer required (effectively always-on) + #[arg(short = 'c')] + _copy: bool, + + /// Printer destination (IPP URI, e.g., ipp://host:631/ipp/print) + #[arg(short = 'd')] + dest: Option, + + /// Number of copies to print + #[arg(short = 'n', default_value = "1", value_parser = clap::value_parser!(u32).range(1..))] + copies: u32, + + /// Send mail after printing (stub: accepted but not implemented) + #[arg(short = 'm')] + _mail: bool, + + /// Suppress messages (no request ID output) + #[arg(short = 's')] + silent: bool, + + /// Write to terminal after printing (stub: accepted but not implemented) + #[arg(short = 'w')] + _write: bool, + + /// Printer-dependent options (can be specified multiple times) + #[arg(short = 'o', action = clap::ArgAction::Append)] + options: Vec, + + /// Title for the banner page + #[arg(short = 't')] + title: Option, + + /// Files to print (use '-' for stdin) + #[arg()] + files: Vec, +} + +/// Get the printer destination from args or environment +fn get_destination(args: &Args) -> Result { + // Priority: -d > LPDEST > PRINTER + if let Some(ref dest) = args.dest { + return Ok(dest.clone()); + } + + if let Ok(dest) = env::var("LPDEST") { + if !dest.is_empty() { + return Ok(dest); + } + } + + if let Ok(dest) = env::var("PRINTER") { + if !dest.is_empty() { + return Ok(dest); + } + } + + Err(gettext("no destination specified")) +} + +/// Validate that the destination is a valid IPP URI +fn validate_uri(dest: &str) -> Result { + // Must start with ipp:// + if !dest.starts_with("ipp://") { + return Err(gettext("invalid destination URI (must be ipp://...)")); + } + + dest.parse::() + .map_err(|_| gettext("invalid destination URI")) +} + +/// Read input data from a file or stdin. +/// Note: Copy mode (-c) is effectively always-on since IPP requires full data upload. +fn read_input(path: &Path) -> Result, io::Error> { + let path_str = path.to_string_lossy(); + if path_str == "-" { + let mut data = Vec::new(); + io::stdin().read_to_end(&mut data)?; + Ok(data) + } else { + let mut file = File::open(path)?; + let mut data = Vec::new(); + file.read_to_end(&mut data)?; + Ok(data) + } +} + +/// Get the current username for the requesting-user-name attribute +fn get_username() -> String { + env::var("USER") + .or_else(|_| env::var("LOGNAME")) + .unwrap_or_else(|_| String::from("anonymous")) +} + +/// Format request ID output +fn format_request_id(dest: &str, job_id: i32) -> String { + // Format: request id is - + format!("request id is {}-{}\n", dest, job_id) +} + +/// Send a print job to the printer +fn send_print_job( + uri: &Uri, + data: Vec, + args: &Args, + file_name: Option<&str>, +) -> Result { + let payload = IppPayload::new(Cursor::new(data)); + + // Build the print job operation + let mut builder = + IppOperationBuilder::print_job(uri.clone(), payload).user_name(get_username()); + + // Set job title + if let Some(ref title) = args.title { + builder = builder.job_title(title); + } else if let Some(name) = file_name { + builder = builder.job_title(name); + } + + // Set copies if more than 1 + if args.copies > 1 { + builder = builder.attribute(IppAttribute::new( + "copies", + IppValue::Integer(args.copies as i32), + )); + } + + // Apply -o options as IPP attributes + // Format expected: name=value + for opt in &args.options { + if let Some((name, value)) = opt.split_once('=') { + // Try to parse as integer, otherwise use as text + let ipp_value = if let Ok(int_val) = value.parse::() { + IppValue::Integer(int_val) + } else if value.eq_ignore_ascii_case("true") { + IppValue::Boolean(true) + } else if value.eq_ignore_ascii_case("false") { + IppValue::Boolean(false) + } else { + IppValue::Keyword(value.to_string()) + }; + builder = builder.attribute(IppAttribute::new(name, ipp_value)); + } + // Ignore options without '=' as they're not valid IPP attribute format + } + + let operation = builder.build(); + + // Create client and send + let client = IppClient::new(uri.clone()); + let response = client + .send(operation) + .map_err(|e| format!("{}: {}", gettext("printer error"), e))?; + + // Check response status + let status = response.header().status_code(); + if !status.is_success() { + return Err(format!( + "{}: {} (0x{:04x})", + gettext("printer error"), + status, + status.to_u16().unwrap_or(0) + )); + } + + // Extract job-id from response + let job_id = response + .attributes() + .groups_of(DelimiterTag::JobAttributes) + .flat_map(|g| g.attributes().get("job-id")) + .flat_map(|attr| attr.value().as_integer().copied()) + .next() + .unwrap_or(0); + + Ok(job_id) +} + +fn do_lp(mut args: Args) -> Result<(), String> { + // Get and validate destination + let dest = get_destination(&args)?; + let uri = validate_uri(&dest)?; + + // Determine input sources + let files: Vec = if args.files.is_empty() { + vec![PathBuf::from("-")] + } else { + std::mem::take(&mut args.files) + }; + + // Process each file + for file in &files { + let file_name = if file.to_string_lossy() == "-" { + None + } else { + file.file_name().and_then(|s| s.to_str()) + }; + + // Read input data + let data = read_input(file) + .map_err(|e| format!("{} '{}': {}", gettext("cannot open"), file.display(), e))?; + + // Send print job + let job_id = send_print_job(&uri, data, &args, file_name)?; + + // Output request ID unless silent + if !args.silent { + print!("{}", format_request_id(&dest, job_id)); + } + } + + Ok(()) +} + +fn main() -> ExitCode { + setlocale(LocaleCategory::LcAll, ""); + textdomain("posixutils-rs").ok(); + bind_textdomain_codeset("posixutils-rs", "UTF-8").ok(); + + let args = Args::parse(); + + match do_lp(args) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("lp: {}", e); + ExitCode::FAILURE + } + } +} diff --git a/print/tests/lp/mod.rs b/print/tests/lp/mod.rs new file mode 100644 index 00000000..d94604fb --- /dev/null +++ b/print/tests/lp/mod.rs @@ -0,0 +1,230 @@ +// +// Copyright (c) 2024 Jeff Garzik +// +// This file is part of the posixutils-rs project covered under +// the MIT License. For the full license text, please see the LICENSE +// file in the root directory of this project. +// SPDX-License-Identifier: MIT +// + +use plib::testing::{run_test_with_checker_and_env, run_test_with_env, TestPlan}; + +/// Test that lp fails when no destination is specified +#[test] +fn lp_no_destination_error() { + // Use empty strings to clear the environment variables in the subprocess + run_test_with_env( + TestPlan { + cmd: String::from("lp"), + args: vec![], + stdin_data: String::from("test data"), + expected_out: String::from(""), + expected_err: String::from("lp: no destination specified\n"), + expected_exit_code: 1, + }, + &[("LPDEST", ""), ("PRINTER", "")], + ); +} + +/// Test that lp fails with invalid URI +#[test] +fn lp_invalid_uri_error() { + run_test_with_checker_and_env( + TestPlan { + cmd: String::from("lp"), + args: vec!["-d".to_string(), "not-a-uri".to_string()], + stdin_data: String::from("test data"), + expected_out: String::from(""), + expected_err: String::from(""), + expected_exit_code: 1, + }, + &[("LPDEST", ""), ("PRINTER", "")], + |_, output| { + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("invalid destination URI"), + "Expected invalid URI error, got: {}", + stderr + ); + assert_eq!(output.status.code(), Some(1)); + }, + ); +} + +/// Test that -m option is accepted (stub implementation) +#[test] +fn lp_m_option_accepted() { + run_test_with_checker_and_env( + TestPlan { + cmd: String::from("lp"), + args: vec![ + "-m".to_string(), + "-d".to_string(), + "ipp://localhost/ipp/print".to_string(), + ], + stdin_data: String::from("test data"), + expected_out: String::from(""), + expected_err: String::from(""), + expected_exit_code: 1, + }, + &[("LPDEST", ""), ("PRINTER", "")], + |_, output| { + let stderr = String::from_utf8_lossy(&output.stderr); + // -m should be accepted; any failure should be printer error, not option error + assert!( + !stderr.contains("-m option not supported"), + "Expected -m to be accepted, but got: {}", + stderr + ); + assert!( + stderr.contains("printer error"), + "Expected printer error (no printer available), got: {}", + stderr + ); + }, + ); +} + +/// Test that -w option is accepted (stub implementation) +#[test] +fn lp_w_option_accepted() { + run_test_with_checker_and_env( + TestPlan { + cmd: String::from("lp"), + args: vec![ + "-w".to_string(), + "-d".to_string(), + "ipp://localhost/ipp/print".to_string(), + ], + stdin_data: String::from("test data"), + expected_out: String::from(""), + expected_err: String::from(""), + expected_exit_code: 1, + }, + &[("LPDEST", ""), ("PRINTER", "")], + |_, output| { + let stderr = String::from_utf8_lossy(&output.stderr); + // -w should be accepted; any failure should be printer error, not option error + assert!( + !stderr.contains("-w option not supported"), + "Expected -w to be accepted, but got: {}", + stderr + ); + assert!( + stderr.contains("printer error"), + "Expected printer error (no printer available), got: {}", + stderr + ); + }, + ); +} + +/// Test that lp fails when file does not exist +#[test] +fn lp_file_not_found() { + run_test_with_checker_and_env( + TestPlan { + cmd: String::from("lp"), + args: vec![ + "-d".to_string(), + "ipp://localhost/ipp/print".to_string(), + "/nonexistent/file/path.txt".to_string(), + ], + stdin_data: String::from(""), + expected_out: String::from(""), + expected_err: String::from(""), + expected_exit_code: 1, + }, + &[("LPDEST", ""), ("PRINTER", "")], + |_, output| { + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("cannot open") && stderr.contains("/nonexistent/file/path.txt"), + "Expected cannot open error, got: {}", + stderr + ); + assert_eq!(output.status.code(), Some(1)); + }, + ); +} + +/// Test LPDEST environment variable is used when -d is not specified +#[test] +fn lp_lpdest_env_used() { + run_test_with_checker_and_env( + TestPlan { + cmd: String::from("lp"), + args: vec![], + stdin_data: String::from("test data"), + expected_out: String::from(""), + expected_err: String::from(""), + expected_exit_code: 1, + }, + &[("LPDEST", "not-ipp-uri"), ("PRINTER", "")], + |_, output| { + let stderr = String::from_utf8_lossy(&output.stderr); + // Should fail because the URI from LPDEST is not valid ipp:// + assert!( + stderr.contains("invalid destination URI"), + "Expected invalid URI error from LPDEST, got: {}", + stderr + ); + assert_eq!(output.status.code(), Some(1)); + }, + ); +} + +/// Test PRINTER environment variable is used when -d and LPDEST are not set +#[test] +fn lp_printer_env_used() { + run_test_with_checker_and_env( + TestPlan { + cmd: String::from("lp"), + args: vec![], + stdin_data: String::from("test data"), + expected_out: String::from(""), + expected_err: String::from(""), + expected_exit_code: 1, + }, + &[("LPDEST", ""), ("PRINTER", "not-ipp-uri")], + |_, output| { + let stderr = String::from_utf8_lossy(&output.stderr); + // Should fail because the URI from PRINTER is not valid ipp:// + assert!( + stderr.contains("invalid destination URI"), + "Expected invalid URI error from PRINTER, got: {}", + stderr + ); + assert_eq!(output.status.code(), Some(1)); + }, + ); +} + +/// Test that -d takes precedence over LPDEST +#[test] +fn lp_d_overrides_lpdest() { + run_test_with_checker_and_env( + TestPlan { + cmd: String::from("lp"), + args: vec!["-d".to_string(), "not-a-uri".to_string()], + stdin_data: String::from("test data"), + expected_out: String::from(""), + expected_err: String::from(""), + expected_exit_code: 1, + }, + &[ + ("LPDEST", "ipp://should-not-be-used/ipp/print"), + ("PRINTER", ""), + ], + |_, output| { + let stderr = String::from_utf8_lossy(&output.stderr); + // Should fail with the -d value, not the LPDEST value + assert!( + stderr.contains("invalid destination URI"), + "Expected invalid URI error from -d, got: {}", + stderr + ); + assert_eq!(output.status.code(), Some(1)); + }, + ); +} diff --git a/print/tests/print-tests.rs b/print/tests/print-tests.rs new file mode 100644 index 00000000..953f8bd0 --- /dev/null +++ b/print/tests/print-tests.rs @@ -0,0 +1 @@ +mod lp;