Skip to content

Commit 22051b3

Browse files
committed
fix: tighten error handling and secret hygiene across helpers
A grab-bag of small but security- and operability-relevant fixes uncovered while reworking the container system. * `compose.yaml`: drop the inline `MyAccessToken` default and the inline SQLite `connect_url` default from the production compose file. Both are credentials/connection strings and now use bare `${VAR}` form per ADR-T-009 §8.1; the dev sandbox keeps its defaults in `compose.override.yaml`. * `index-config`: stop printing config-loading messages to stdout. The TOML body may contain DB connect URLs, API tokens, or SMTP passwords — log only the env-var name through `tracing` (stderr) so we don't pollute the JSON-only stdout contract used by helper binaries (P9). Only the env-var *name* is ever emitted, never its value. * `index-config`: fix the `[net.tls]` defaulting quirk where an empty table synthesised `Some("")` paths and later failed at TLS load time with a misleading "no such file" error. Missing fields now default to `None`, matching the empty-string behaviour. New regression test in `quirks.rs`. * `index-health-check`: resolve `host:port` ourselves and use `TcpStream::connect_timeout` so the probe fails fast instead of inheriting the OS's multi-tens-of-seconds SYN-retransmit schedule — important for orchestrator health checks. Stop swallowing `set_{read,write}_timeout` errors silently. Return `ExitCode::FAILURE` on stdout-write failure (e.g. broken pipe) instead of panicking. * `index-auth-keypair`: same broken-pipe fix — honour the helper's documented exit-code contract on stdout write failure rather than panicking out of `unwrap()`. * `index-entry-script`: correct the `run_sh_with_args` doc-comment. Arguments are forwarded as `argv` entries to `sh -c` (with a `"sh"` placeholder for `$0`), not spliced into the script text; no quoting is performed or required. * `require_permission` extractor: stop collapsing every database error into a 404 `UserNotFound`. Only the genuine `Error::UserNotFound` becomes a 404; DB/IO failures now surface as `DatabaseError` (500) and are logged, so operator-visible outages aren't masked as missing users. * `AGENTS.md`: rewrite the misleading "POSIX paths must be null-terminated" guidance. Paths are opaque byte sequences; prefer `OsStr`/`PathBuf` (or `Utf8Path` when UTF-8 really is a precondition); NUL termination is only relevant at the libc/FFI boundary.
1 parent 46d0221 commit 22051b3

9 files changed

Lines changed: 119 additions & 45 deletions

File tree

AGENTS.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,20 @@ Every test file (module) should maintain an index of the tests contained in the
5151

5252
## POSIX Paths
5353

54-
All paths must be handled using null-terminated strings. No assumptions should be made about which characters may appear in file or directory names.
54+
Treat paths as opaque byte sequences. POSIX permits any byte except
55+
`\0` (NUL) and `/` (the path separator) in a file or directory name,
56+
and there is no guarantee that the bytes are valid UTF-8. Concretely:
57+
58+
- Prefer `OsStr` / `OsString` / `Path` / `PathBuf` (or `Utf8Path`
59+
when UTF-8 really is a precondition you intend to enforce) over
60+
ad-hoc `String` handling.
61+
- Do not assume any particular character class — names may contain
62+
spaces, newlines, control bytes, leading dashes, or arbitrary
63+
non-UTF-8 bytes.
64+
- NUL termination is only required when crossing a libc/FFI
65+
boundary (e.g. `CString` for `open(2)`); interior NUL bytes are
66+
invalid for those APIs and must be rejected, not silently
67+
truncated.
5568

5669
## Cross-Reference Conventions
5770

compose.yaml

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,15 @@ services:
3131
- TORRUST_INDEX_CONFIG_TOML
3232
- TORRUST_INDEX_DATABASE=${TORRUST_INDEX_DATABASE:-torrust_index}
3333
- TORRUST_INDEX_DATABASE_DRIVER=${TORRUST_INDEX_DATABASE_DRIVER:-sqlite3}
34-
# DEV-ONLY default — change for any public or production deployment.
35-
- TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN=${TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN:-MyAccessToken}
34+
# Credential — bare ${VAR}, no default. See ADR-T-009 §8.1.
35+
# Dev sandbox defaults live in `compose.override.yaml`.
36+
- TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN=${TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN}
3637
# Mandatory at the schema level (ADR-T-009 §D2): the shipped
3738
# default TOMLs no longer carry a `database.connect_url`, so the
38-
# operator must inject one. The defaults below match the SQLite
39-
# path materialised by the entry script and the MySQL service in
40-
# this compose file.
41-
- TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL=${TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL:-sqlite:///var/lib/torrust/index/database/sqlite3.db?mode=rwc}
39+
# operator must inject one. Bare ${VAR} keeps the production
40+
# baseline credential-clean; the dev sandbox supplies a SQLite
41+
# default in `compose.override.yaml`.
42+
- TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL=${TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL}
4243
networks:
4344
- server_side
4445
ports:

packages/index-auth-keypair/src/bin/torrust-index-auth-keypair.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,15 @@ fn main() -> ExitCode {
3838
match generate_keypair() {
3939
Ok(out) => {
4040
info!("Key pair generated successfully.");
41-
emit(&out).unwrap();
42-
ExitCode::SUCCESS
41+
match emit(&out) {
42+
Ok(()) => ExitCode::SUCCESS,
43+
Err(e) => {
44+
// Writing to stdout failed (e.g. broken pipe). Honour
45+
// the helper's exit-code contract instead of panicking.
46+
error!(error = %e, "failed to write key pair to stdout");
47+
ExitCode::FAILURE
48+
}
49+
}
4350
}
4451
Err(e) => {
4552
error!(error = %e, "keypair generation failed");

packages/index-config/src/lib.rs

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -202,20 +202,28 @@ impl Info {
202202
pub fn new(default_config_toml_path: String) -> Result<Self, Error> {
203203
let info = Self::from_env(&default_config_toml_path);
204204

205-
if let Some(toml) = info.config_toml.as_ref() {
206-
println!("Loading extra configuration from environment variable {toml} ...");
205+
if info.config_toml.is_some() {
206+
// The TOML body may contain secrets (DB connect URLs, API
207+
// tokens, SMTP passwords, …) so log only the env-var name
208+
// — never its value — and route through `tracing` (stderr)
209+
// so we don't pollute the JSON-only stdout contract used
210+
// by helper binaries (P9).
211+
tracing::info!(
212+
env_var = ENV_VAR_CONFIG_TOML,
213+
"loading extra configuration from environment variable"
214+
);
207215
}
208216

209217
if env::var(ENV_VAR_CONFIG_TOML_PATH)
210218
.ok()
211219
.as_ref()
212220
.is_some_and(|s| !s.is_empty())
213221
{
214-
println!("Loading extra configuration from file: `{}` ...", info.config_toml_path);
222+
tracing::info!(path = %info.config_toml_path, "loading extra configuration from file");
215223
} else {
216-
println!(
217-
"Loading extra configuration from default configuration file: `{}` ...",
218-
info.config_toml_path
224+
tracing::info!(
225+
path = %info.config_toml_path,
226+
"loading extra configuration from default configuration file"
219227
);
220228
}
221229

@@ -292,31 +300,27 @@ impl From<figment::Error> for Error {
292300
/// It's the port number `0`
293301
pub const FREE_PORT: u16 = 0;
294302

303+
/// TLS configuration for the HTTPS endpoint.
304+
///
305+
/// Both fields default to `None` (treated as "not configured") when
306+
/// absent **or** when given as an empty string. An incomplete
307+
/// `[net.tls]` table — for example one missing `ssl_key_path` — therefore
308+
/// behaves identically to `[net.tls]` being absent altogether, rather
309+
/// than being silently coerced into a `Some("")` that would later fail
310+
/// at TLS load time with a misleading "no such file" error.
295311
#[serde_as]
296312
#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Default)]
297313
pub struct Tls {
298314
/// Path to the SSL certificate file.
299315
#[serde_as(as = "NoneAsEmptyString")]
300-
#[serde(default = "Tls::default_ssl_cert_path")]
316+
#[serde(default)]
301317
pub ssl_cert_path: Option<Utf8PathBuf>,
302318
/// Path to the SSL key file.
303319
#[serde_as(as = "NoneAsEmptyString")]
304-
#[serde(default = "Tls::default_ssl_key_path")]
320+
#[serde(default)]
305321
pub ssl_key_path: Option<Utf8PathBuf>,
306322
}
307323

308-
impl Tls {
309-
#[allow(clippy::unnecessary_wraps)]
310-
fn default_ssl_cert_path() -> Option<Utf8PathBuf> {
311-
Some(Utf8PathBuf::new())
312-
}
313-
314-
#[allow(clippy::unnecessary_wraps)]
315-
fn default_ssl_key_path() -> Option<Utf8PathBuf> {
316-
Some(Utf8PathBuf::new())
317-
}
318-
}
319-
320324
/// Loads the settings from the [`Info`] struct.
321325
///
322326
/// The whole configuration in TOML format is included in

packages/index-config/src/tests/quirks.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
//! | Test | What it proves |
1212
//! |------------------------------------------------------|----------------------------------------------------------------------|
1313
//! | `tls_empty_string_paths_deserialise_to_none` | The `NoneAsEmptyString` adapter empties out → `None`. |
14+
//! | `tls_section_with_no_fields_deserialises_to_none` | An empty `[net.tls]` table leaves both paths as `None`. |
1415
//! | `ipv6_bind_address_round_trips` | `[::1]:7000` survives parse → serialise → parse. |
1516
//! | `tracker_token_accepts_unicode_grapheme_clusters` | A 🦀-laden token loads and pretty-prints intact. |
1617
//! | `tracker_validator_rejects_udp_private` | The classic "you can't have a private UDP tracker" guard fires. |
@@ -41,6 +42,18 @@ fn tls_empty_string_paths_deserialise_to_none() {
4142
assert!(tls.ssl_key_path.is_none(), "empty string must collapse to None");
4243
}
4344

45+
#[test]
46+
fn tls_section_with_no_fields_deserialises_to_none() {
47+
// A `[net.tls]` table with no fields at all must not synthesise empty
48+
// paths — otherwise `make_rust_tls` would later try to load a cert
49+
// from `""` and emit a misleading "no such file" error at startup.
50+
let toml = format!("{MINIMUM_VALID_TOML}\n[net.tls]\n");
51+
let settings = load_settings(&info_from(&toml)).expect("TOML must load");
52+
let tls = settings.net.tls.expect("[net.tls] table must materialise as Some");
53+
assert!(tls.ssl_cert_path.is_none(), "missing field must default to None");
54+
assert!(tls.ssl_key_path.is_none(), "missing field must default to None");
55+
}
56+
4457
#[test]
4558
fn ipv6_bind_address_round_trips() {
4659
let mut s = placeholder_settings();

packages/index-entry-script/src/lib.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,8 @@ pub fn run_sh(snippet: &str) -> Output {
108108
.expect("failed to spawn sh; is /bin/sh available?")
109109
}
110110

111-
/// Same as [`run_sh`], but quotes each argument and passes
112-
/// them as `$1`, `$2`, … to the snippet.
111+
/// Same as [`run_sh`], but forwards each argument to the snippet
112+
/// as a positional parameter (`$1`, `$2`, …).
113113
///
114114
/// This is the natural shape for testing functions that take
115115
/// positional arguments (e.g. `validate_auth_keys`):
@@ -121,10 +121,14 @@ pub fn run_sh(snippet: &str) -> Output {
121121
/// );
122122
/// ```
123123
///
124-
/// Each argument is single-quoted; embedded single quotes are
125-
/// escaped using the standard `'\''` POSIX trick. This makes
126-
/// the wrapper safe for arbitrary string arguments — the
127-
/// tests never need to think about shell metacharacters.
124+
/// Arguments are **not** spliced into the script text — they are
125+
/// passed as additional `argv` entries to `sh -c`, after a `"sh"`
126+
/// placeholder for `$0`. `sh` then exposes them to the snippet as
127+
/// `$1`, `$2`, … (and through `"$@"`). Because nothing is
128+
/// interpolated into the script string, no quoting or
129+
/// metacharacter escaping is required: arbitrary byte sequences
130+
/// (single quotes, backslashes, spaces, …) are forwarded verbatim
131+
/// by the kernel's `execve` boundary.
128132
#[doc(hidden)]
129133
#[must_use]
130134
pub fn run_sh_with_args(snippet: &str, args: &[&str]) -> Output {

packages/index-health-check/src/bin/torrust-index-health-check.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,17 @@ fn main() -> ExitCode {
3333
});
3434

3535
match do_health_check(&args.url) {
36-
Ok(out) => {
37-
emit(&out).unwrap();
38-
ExitCode::SUCCESS
39-
}
36+
Ok(out) => match emit(&out) {
37+
Ok(()) => ExitCode::SUCCESS,
38+
Err(e) => {
39+
// Writing the JSON object to stdout failed (e.g. broken
40+
// pipe when the consumer has already exited). Surface
41+
// the failure through the documented exit-code contract
42+
// rather than letting Rust's panic handler set its own.
43+
error!(error = %e, "failed to write health-check output to stdout");
44+
ExitCode::FAILURE
45+
}
46+
},
4047
Err(e) => {
4148
error!(error = %e, "health check failed");
4249
ExitCode::FAILURE

packages/index-health-check/src/lib.rs

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
//! Uses only `std::net::TcpStream` — no async runtime, no TLS crate.
44
55
use std::io::{BufRead, BufReader, Write};
6-
use std::net::TcpStream;
6+
use std::net::{TcpStream, ToSocketAddrs};
77
use std::time::{Duration, Instant};
88

99
use serde::Serialize;
@@ -44,9 +44,26 @@ pub fn do_health_check(url: &str) -> Result<HealthCheckOutput, HealthCheckError>
4444
.map_or((stripped, "/"), |i| (&stripped[..i], &stripped[i..]));
4545

4646
let timeout = Duration::from_secs(5);
47-
let mut stream = TcpStream::connect(host_port).map_err(|e| HealthCheckError(format!("connect to {host_port}: {e}")))?;
48-
stream.set_read_timeout(Some(timeout)).ok();
49-
stream.set_write_timeout(Some(timeout)).ok();
47+
48+
// Resolve `host:port` ourselves so we can apply an explicit
49+
// connect-timeout: `TcpStream::connect` falls back to the OS's
50+
// SYN-retransmit schedule (often tens of seconds), which is far
51+
// too long for a container orchestrator's health probe.
52+
let mut addrs = host_port
53+
.to_socket_addrs()
54+
.map_err(|e| HealthCheckError(format!("resolve {host_port}: {e}")))?;
55+
let addr = addrs
56+
.next()
57+
.ok_or_else(|| HealthCheckError(format!("resolve {host_port}: no addresses returned")))?;
58+
59+
let mut stream = TcpStream::connect_timeout(&addr, timeout)
60+
.map_err(|e| HealthCheckError(format!("connect to {host_port} ({addr}): {e}")))?;
61+
stream
62+
.set_read_timeout(Some(timeout))
63+
.map_err(|e| HealthCheckError(format!("set read timeout: {e}")))?;
64+
stream
65+
.set_write_timeout(Some(timeout))
66+
.map_err(|e| HealthCheckError(format!("set write timeout: {e}")))?;
5067

5168
let request = format!("GET {path} HTTP/1.1\r\nHost: {host_port}\r\nConnection: close\r\n\r\n");
5269
stream

src/web/api/server/v1/extractors/require_permission.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,16 @@ where
188188
return Err(AuthError::UnrecognisedRole.into_response());
189189
}
190190
}
191-
Err(_) => {
192-
return Err(AuthError::UserNotFound.into_response());
191+
Err(err) => {
192+
// Preserve the underlying error category: only the
193+
// genuine "user not found" case becomes a 404. DB
194+
// / IO failures must surface as `DatabaseError`
195+
// (500), not as a misleading 404 that masks the
196+
// outage from operators.
197+
if !matches!(err, crate::databases::database::Error::UserNotFound) {
198+
tracing::error!(user_id, %err, "failed to load user for permission check");
199+
}
200+
return Err(AuthError::from(err).into_response());
193201
}
194202
};
195203

0 commit comments

Comments
 (0)