Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 19 additions & 43 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
iocraft = "0.7.18"
urlencoding = "2.1"
uuid = { version = "1.22", features = ["v4"] }
tiny_http = "0.12"

[dev-dependencies]
insta = "1.46"
Expand Down
47 changes: 7 additions & 40 deletions src/auth.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
use std::{fs, io::Write};

use anyhow::{Context, Result, anyhow};
use figment::{
Figment,
providers::{Env, Format, Json},
};
use serde::{Deserialize, Serialize};

use crate::dirs::{auth_file_path, create_private_dir};
use crate::dirs::{auth_file_path, write_private_atomic};

#[derive(Debug, Clone, Serialize, Deserialize)]
struct AuthPayload {
Expand Down Expand Up @@ -76,52 +74,21 @@ pub fn write_auth(email: &str, password: &str) -> Result<std::path::PathBuf> {

fn write_auth_at(path: &std::path::Path, email: &str, password: &str) -> Result<()> {
let (email, password) = validate_auth(email, password)?;
let parent = path
.parent()
.ok_or_else(|| anyhow!("Invalid auth file path"))?;
create_private_dir(parent).with_context(|| format!("Failed creating {}", parent.display()))?;

let payload = AuthPayload { email, password };
let serialized = serde_json::to_string(&payload)?;
let tmp_path = path.with_extension("tmp");

// Create the staging file already private: chmod'ing after the fact leaves
// the plaintext password world-readable in between, and create_new won't
// follow a symlink planted at tmp_path. The rename carries the mode over.
let mut opts = fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
// Clear a stale staging file from an interrupted run; create_new refuses it.
let _ = fs::remove_file(&tmp_path);
let mut file = opts
.open(&tmp_path)
.with_context(|| format!("Failed writing {}", tmp_path.display()))?;
// open() filters the requested mode through the umask, which can clear
// owner bits too, so restate it before the password goes in. The file is
// already no wider than 0600, so this opens no window.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o600))
.with_context(|| format!("Failed securing {}", tmp_path.display()))?;
}
file.write_all(serialized.as_bytes())
.with_context(|| format!("Failed writing {}", tmp_path.display()))?;

fs::rename(&tmp_path, path).with_context(|| format!("Failed finalizing {}", path.display()))?;

// Mode 0600, and do not follow a symlink at the staging path or the final path.
write_private_atomic(path, serialized.as_bytes())
.with_context(|| format!("Failed writing {}", path.display()))?;
Ok(())
}

#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::fs;
use std::os::unix::fs::PermissionsExt;

use super::*;

#[test]
fn auth_file_is_created_private() {
let dir = tempfile::tempdir().expect("tempdir");
Expand Down
78 changes: 73 additions & 5 deletions src/client.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::{
collections::BTreeMap,
fmt,
time::{SystemTime, UNIX_EPOCH},
};

Expand Down Expand Up @@ -35,7 +36,7 @@ pub(crate) fn now_timestamp() -> f64 {
now_ts()
}

#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct ThingsCloudClient {
pub email: String,
pub password: String,
Expand All @@ -44,6 +45,39 @@ pub struct ThingsCloudClient {
http: Client,
}

impl fmt::Debug for ThingsCloudClient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ThingsCloudClient")
.field("email", &"<redacted>")
.field("password", &"<redacted>")
.field(
"history_key",
&self.history_key.as_ref().map(|_| "<redacted>"),
)
.field("head_index", &self.head_index)
.finish_non_exhaustive()
}
}

fn redact_url(url: &str) -> String {
if let Some(index) = url.find("/history/") {
let rest = &url[index + "/history/".len()..];
let key_end = rest.find('/').unwrap_or(rest.len());
let mut out = String::with_capacity(url.len());
out.push_str(&url[..index]);
out.push_str("/history/<redacted>");
out.push_str(&rest[key_end..]);
return out;
}
if let Some(index) = url.find("/account/") {
let mut out = String::with_capacity(index + "/account/<redacted>".len());
out.push_str(&url[..index]);
out.push_str("/account/<redacted>");
return out;
}
url.to_string()
}

impl ThingsCloudClient {
pub fn new(email: String, password: String) -> Result<Self> {
let http = Client::builder().build()?;
Expand Down Expand Up @@ -85,20 +119,29 @@ impl ThingsCloudClient {
.json(&payload);
}

let safe_url = redact_url(url);
let resp = req
.send()
.with_context(|| format!("request failed: {url}"))?;
.with_context(|| format!("request failed: {safe_url}"))?;
let status = resp.status();
let text = resp.text().with_context(|| {
format!("failed reading body from {url} (HTTP {})", status.as_u16())
format!(
"failed reading body from {safe_url} (HTTP {})",
status.as_u16()
)
})?;
if !status.is_success() {
return Err(anyhow!("HTTP {} for {}: {}", status.as_u16(), url, text));
return Err(anyhow!(
"HTTP {} for {}: {}",
status.as_u16(),
safe_url,
text
));
}
if text.trim().is_empty() {
return Ok(json!({}));
}
serde_json::from_str(&text).with_context(|| format!("invalid json from {url}"))
serde_json::from_str(&text).with_context(|| format!("invalid json from {safe_url}"))
}

pub fn authenticate(&mut self) -> Result<String> {
Expand Down Expand Up @@ -205,3 +248,28 @@ impl ThingsCloudClient {
Ok(new_index)
}
}

#[cfg(test)]
mod tests {
use super::redact_url;

#[test]
fn redacts_history_key_and_account_email() {
assert_eq!(
redact_url(
"https://cloud.culturedcode.com/version/1/history/SECRETKEY/items?start-index=0"
),
"https://cloud.culturedcode.com/version/1/history/<redacted>/items?start-index=0"
);
assert_eq!(
redact_url(
"https://cloud.culturedcode.com/version/1/history/SECRETKEY/commit?ancestor-index=3&_cnt=1"
),
"https://cloud.culturedcode.com/version/1/history/<redacted>/commit?ancestor-index=3&_cnt=1"
);
assert_eq!(
redact_url("https://cloud.culturedcode.com/version/1/account/user%40example.com"),
"https://cloud.culturedcode.com/version/1/account/<redacted>"
);
}
}
Loading