truss-image 0.23.0

Image toolkit with a shared Rust core across the CLI, HTTP server, and WASM demo.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
use crate::adapters::server::{self, ServerConfig};
use std::io::{self, Write};
use std::net::TcpListener;

use crate::core::error_class::ErrorClass;

use super::{
    ClapServeArgs, ClapValidateArgs, CliError, Command, EXIT_RUNTIME, EXIT_USAGE, HelpTopic,
    ServeCommand, runtime_error, serve_usage, usage_error,
};

// ---------------------------------------------------------------------------
// Clap -> Command conversion
// ---------------------------------------------------------------------------

pub(super) fn serve_from_clap(args: ClapServeArgs) -> Result<Command, CliError> {
    if args.help {
        return Ok(Command::Help(HelpTopic::Serve));
    }

    Ok(Command::Serve(ServeCommand {
        bind_addr: args.bind,
        storage_root: args.storage_root,
        public_base_url: args.public_base_url,
        signed_url_key_id: args.signed_url_key_id,
        signed_url_secret: args.signed_url_secret,
        allow_insecure_url_sources: args.allow_insecure_url_sources,
    }))
}

pub(super) fn validate_from_clap(args: ClapValidateArgs) -> Result<Command, CliError> {
    if args.help {
        return Ok(Command::Help(HelpTopic::Validate));
    }
    Ok(Command::Validate)
}

// ---------------------------------------------------------------------------
// Execution
// ---------------------------------------------------------------------------

pub(super) fn execute_serve(command: ServeCommand) -> Result<(), CliError> {
    let bind_addr = command.bind_addr.clone().unwrap_or_else(server::bind_addr);
    let config = resolve_server_config(command)?;
    let listener = TcpListener::bind(&bind_addr).map_err(|error| {
        runtime_error(
            EXIT_RUNTIME,
            &format!("failed to bind {bind_addr}: {error}"),
        )
    })?;
    let listen_addr = listener.local_addr().map_err(|error| {
        runtime_error(
            EXIT_RUNTIME,
            &format!("failed to read listener address: {error}"),
        )
    })?;
    let mut stdout = io::stdout().lock();

    // Server startup summary
    writeln!(stdout, "truss listening on http://{listen_addr}").map_err(|error| {
        runtime_error(EXIT_RUNTIME, &format!("failed to write stdout: {error}"))
    })?;
    writeln!(stdout, "  storage root: {}", config.storage_root.display()).map_err(|error| {
        runtime_error(EXIT_RUNTIME, &format!("failed to write stdout: {error}"))
    })?;

    // Signed URL verification status
    let signed_url_enabled = !config.signing_keys.is_empty()
        || (config.signed_url_key_id.is_some() && config.signed_url_secret.is_some());
    writeln!(
        stdout,
        "  signed URL verification: {}",
        if signed_url_enabled {
            "enabled"
        } else {
            "disabled"
        }
    )
    .map_err(|error| runtime_error(EXIT_RUNTIME, &format!("failed to write stdout: {error}")))?;

    // Bearer token status (never show the value)
    writeln!(
        stdout,
        "  private API bearer token: {}",
        if config.bearer_token.is_some() {
            "configured"
        } else {
            "not set"
        }
    )
    .map_err(|error| runtime_error(EXIT_RUNTIME, &format!("failed to write stdout: {error}")))?;

    // Cache status
    writeln!(
        stdout,
        "  cache: {}",
        if config.cache_root.is_some() {
            "enabled"
        } else {
            "disabled"
        }
    )
    .map_err(|error| runtime_error(EXIT_RUNTIME, &format!("failed to write stdout: {error}")))?;

    if let Some(ref public_base_url) = config.public_base_url {
        writeln!(stdout, "  public base URL: {public_base_url}").map_err(|error| {
            runtime_error(EXIT_RUNTIME, &format!("failed to write stdout: {error}"))
        })?;
    }
    if config.allow_insecure_url_sources {
        writeln!(stdout, "  insecure URL sources: enabled").map_err(|error| {
            runtime_error(EXIT_RUNTIME, &format!("failed to write stdout: {error}"))
        })?;
    }
    stdout.flush().map_err(|error| {
        runtime_error(EXIT_RUNTIME, &format!("failed to flush stdout: {error}"))
    })?;

    server::serve_with_config(listener, config)
        .map_err(|error| runtime_error(EXIT_RUNTIME, &format!("server runtime failed: {error}")))
}

pub(super) fn execute_validate<W: Write>(stdout: &mut W) -> Result<(), CliError> {
    match ServerConfig::from_env() {
        Ok(config) => {
            // `validate` takes no flags, so the environment is the whole configuration, and
            // the warning belongs here for the same reason it belongs after the overlay in
            // `resolve_server_config`: it is asked of what the server would run with.
            warn_about_a_missing_public_base_url(&config);
            ensure_storage_is_usable(&config)?;
            writeln!(stdout, "configuration is valid").map_err(|error| {
                runtime_error(EXIT_RUNTIME, &format!("failed to write stdout: {error}"))
            })?;
            writeln!(stdout, "  storage root: {}", config.storage_root.display()).map_err(
                |error| runtime_error(EXIT_RUNTIME, &format!("failed to write stdout: {error}")),
            )?;
            Ok(())
        }
        Err(error) => Err(usage_error(&format!("invalid configuration: {error}"))),
    }
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Refuses a configuration whose storage the server could not serve from.
///
/// [`crate::adapters::server::serve`] runs this before it accepts a connection, and the two
/// CLI commands did not, so a storage root that resolved to a file bound a port and
/// answered 500 to every transform while `truss validate` called the same configuration
/// valid. A storage root that cannot be used is a fault in the configuration, so it is
/// exit 1 here, the code `resolve_server_config` gives every other one.
///
/// For a cloud backend this reaches the endpoint, which is what makes it worth doing before
/// the port is bound rather than after the first request.
fn ensure_storage_is_usable(config: &ServerConfig) -> Result<(), CliError> {
    for (ok, name) in server::storage_health_check(config) {
        if ok {
            continue;
        }
        let detail = if name == "storageRoot" {
            format!(
                "storage root `{}` is not a directory",
                config.storage_root.display()
            )
        } else {
            "the storage backend is not reachable — verify the endpoint, credentials, and \
             the container or bucket"
                .to_string()
        };
        return Err(usage_error(&format!("invalid configuration: {detail}")));
    }
    Ok(())
}

pub(super) fn resolve_server_config(command: ServeCommand) -> Result<ServerConfig, CliError> {
    // A configuration fault exits 1, the same code `truss validate` reports for the same
    // fault. Exit 5 is for what happens after the configuration is accepted — a port
    // already in use, a stream that cannot be written.
    let mut config = ServerConfig::from_env()
        .map_err(|error| usage_error(&format!("failed to load server configuration: {error}")))?;

    if let Some(storage_root) = command.storage_root {
        config.storage_root = storage_root.canonicalize().map_err(|error| {
            usage_error(&format!(
                "failed to resolve storage root {}: {error}",
                storage_root.display()
            ))
        })?;
    }

    if let Some(public_base_url) = command.public_base_url {
        config.public_base_url = Some(public_base_url);
    }

    match (command.signed_url_key_id, command.signed_url_secret) {
        (Some(key_id), Some(secret)) => {
            config = config.with_signed_url_credentials(key_id, secret);
        }
        (Some(_), None) | (None, Some(_)) => {
            return Err(CliError {
                exit_code: EXIT_USAGE,
                class: ErrorClass::InvalidRequest,
                message: "--signed-url-key-id and --signed-url-secret must be provided together"
                    .to_string(),
                usage: Some(serve_usage().to_string()),
                hint: Some("run 'truss serve --help' for serve options".to_string()),
            });
        }
        (None, None) => {}
    }

    if command.allow_insecure_url_sources {
        config.allow_insecure_url_sources = true;
    }

    warn_about_a_missing_public_base_url(&config);
    ensure_storage_is_usable(&config)?;

    Ok(config)
}

/// Warns when signing is on and nothing says what authority the URLs were signed for.
///
/// Asked of the assembled configuration rather than of the environment, because either
/// half can come from either source: the keys from `TRUSS_SIGNING_KEYS` or from
/// `--signed-url-key-id`, the authority from `TRUSS_PUBLIC_BASE_URL` or from
/// `--public-base-url`. Reading only the environment made this fire for a base URL given
/// on the command line and stay silent for keys given the same way, which is the form
/// `truss serve --help` prints.
///
/// The failure it exists to prevent is a quiet one: behind a proxy the Host header is not
/// the authority the URL was signed for, so every signed URL is answered 401 and nothing
/// says why.
fn warn_about_a_missing_public_base_url(config: &ServerConfig) {
    if config.signing_keys.is_empty() || config.public_base_url.is_some() {
        return;
    }
    // Startup, so `eprintln!` rather than the raw-descriptor writer the request path uses.
    eprintln!(
        "truss: warning: signing keys are configured but no public base URL is. Behind a \
         reverse proxy or CDN the Host header may differ from the externally visible \
         authority, causing signed URL verification to fail. Set TRUSS_PUBLIC_BASE_URL or \
         pass --public-base-url with the canonical external origin."
    );
}

#[cfg(test)]
mod tests {
    use super::*;
    use serial_test::serial;
    use std::ffi::OsString;
    use std::io;

    struct EnvVarGuard {
        name: &'static str,
        previous: Option<OsString>,
    }

    impl EnvVarGuard {
        fn set_path(name: &'static str, value: &std::path::Path) -> Self {
            let previous = std::env::var_os(name);
            // SAFETY: test-only env mutation guarded by serial execution.
            unsafe { std::env::set_var(name, value) };
            Self { name, previous }
        }
    }

    impl Drop for EnvVarGuard {
        fn drop(&mut self) {
            match &self.previous {
                Some(previous) => {
                    // SAFETY: restoring the pre-test env var value during test teardown.
                    unsafe { std::env::set_var(self.name, previous) };
                }
                None => {
                    // SAFETY: removing the test-only env var during test teardown.
                    unsafe { std::env::remove_var(self.name) };
                }
            }
        }
    }

    struct FailingWriter;

    impl Write for FailingWriter {
        fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
            Err(io::Error::other("writer failure"))
        }

        fn flush(&mut self) -> io::Result<()> {
            Ok(())
        }
    }

    #[test]
    #[serial]
    fn execute_serve_returns_runtime_error_for_invalid_bind_addr() {
        let storage_root = tempfile::tempdir().expect("create tempdir");
        let _storage_root_guard = EnvVarGuard::set_path("TRUSS_STORAGE_ROOT", storage_root.path());

        let error = execute_serve(ServeCommand {
            bind_addr: Some("invalid-bind-addr".to_string()),
            storage_root: Some(storage_root.path().to_path_buf()),
            public_base_url: None,
            signed_url_key_id: None,
            signed_url_secret: None,
            allow_insecure_url_sources: false,
        })
        .expect_err("invalid bind address should fail");

        assert_eq!(error.exit_code, super::EXIT_RUNTIME);
        assert!(error.message.contains("failed to bind"));
    }

    #[test]
    #[serial]
    fn execute_validate_reports_writer_failures() {
        let storage_root = tempfile::tempdir().expect("create tempdir");
        let _storage_root_guard = EnvVarGuard::set_path("TRUSS_STORAGE_ROOT", storage_root.path());

        let error =
            execute_validate(&mut FailingWriter).expect_err("writer failure should be reported");

        assert_eq!(error.exit_code, super::EXIT_RUNTIME);
        assert!(error.message.contains("failed to write stdout"));
    }

    /// A storage root that is a file resolves, so the configuration parses, and every
    /// request under it fails.
    ///
    /// `serve_with_config` is reached without the check `serve` runs, so the port is bound
    /// and each transform answers 500, while `/health/ready` reports `storageRoot: fail`.
    /// `truss validate` exists to say so before any of that happens.
    #[test]
    #[serial]
    fn a_storage_root_that_is_a_file_is_a_usage_error_from_validate_and_serve() {
        let parent = tempfile::tempdir().expect("create tempdir");
        let file = parent.path().join("root-is-a-file.png");
        std::fs::write(&file, b"not a directory").expect("write the file");
        let _storage_root_guard = EnvVarGuard::set_path("TRUSS_STORAGE_ROOT", &file);

        let validate_error = execute_validate(&mut Vec::new())
            .expect_err("a storage root that is a file is not one");
        assert_eq!(validate_error.exit_code, super::EXIT_USAGE);
        assert!(
            validate_error.message.contains("storage root"),
            "validate should say what it could not use, got: {}",
            validate_error.message
        );

        let serve_error = execute_serve(ServeCommand {
            bind_addr: Some("127.0.0.1:0".to_string()),
            storage_root: Some(file.clone()),
            public_base_url: None,
            signed_url_key_id: None,
            signed_url_secret: None,
            allow_insecure_url_sources: false,
        })
        .expect_err("serve must not bind a port it cannot serve from");
        assert_eq!(serve_error.exit_code, super::EXIT_USAGE);
        assert!(
            serve_error.message.contains("storage root"),
            "serve should say the same thing, got: {}",
            serve_error.message
        );
    }

    #[test]
    #[serial]
    fn a_storage_root_that_does_not_exist_is_a_usage_error_and_names_the_setting() {
        let missing = std::env::temp_dir().join("truss-storage-root-that-does-not-exist");
        let _storage_root_guard = EnvVarGuard::set_path("TRUSS_STORAGE_ROOT", &missing);

        // `truss validate` and `truss serve` disagreed here: the same misconfiguration
        // exited 1 from one and 5 from the other, and neither said which setting it was.
        let validate_error =
            execute_validate(&mut Vec::new()).expect_err("a missing storage root is invalid");
        assert_eq!(validate_error.exit_code, super::EXIT_USAGE);
        assert!(
            validate_error.message.contains("TRUSS_STORAGE_ROOT"),
            "validate should name the setting, got: {}",
            validate_error.message
        );

        let serve_error = resolve_server_config(ServeCommand {
            bind_addr: None,
            storage_root: None,
            public_base_url: None,
            signed_url_key_id: None,
            signed_url_secret: None,
            allow_insecure_url_sources: false,
        })
        .expect_err("a missing storage root is invalid");
        assert_eq!(serve_error.exit_code, super::EXIT_USAGE);
        assert!(
            serve_error.message.contains("TRUSS_STORAGE_ROOT"),
            "serve should name the setting, got: {}",
            serve_error.message
        );
    }
}