src/support/mod.rs
Rama main · réplica de lectura del repositorio original.
//! Privacy-safe offline support export and bounded normal-operation evidence.
#![allow(
clippy::print_stdout,
reason = "support-export prints its local result and review instructions"
)]
mod collect;
pub(crate) mod history;
mod privacy;
pub(crate) mod token_equivalence;
pub(crate) use history::{
CompactionSummary, MigrationFailure, StorageComponent, StorageOutcome, begin, complete,
observe, observe_scoped, record_storage, record_transport_compaction,
record_transport_compaction_failure,
};
pub(crate) use privacy::fixed_label;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
pub(crate) fn history_path(directory: &Path, username: &str, realm: &str) -> PathBuf {
directory.join(format!(
"{}.support.json",
crate::account::namespace(username, realm)
))
}
pub(crate) fn initialize(
globals: &crate::config::GlobalArgs,
toml: Option<&crate::config::TomlConfig>,
explicit: bool,
) -> Option<(history::Recorder, history::Guard)> {
let (username, _, domain, directory) =
crate::config::resolve_auth(globals, &crate::cli::PasswordArgs::default(), toml);
if username.is_empty() {
return None;
}
let configuration = configuration(toml, explicit);
history::start(
history_path(&directory, &username, domain.as_str()),
configuration,
)
}
pub(crate) fn runtime_configuration(config: &crate::config::Config) -> Value {
let mut value =
serde_json::to_value(crate::report::RunOptions::from_config(config)).unwrap_or(Value::Null);
if let Some(map) = value.as_object_mut() {
map.insert(
"file_match_policy".into(),
json!(match config.photos.file_match_policy {
crate::types::FileMatchPolicy::NameSizeDedupWithSuffix =>
"name-size-dedup-with-suffix",
crate::types::FileMatchPolicy::NameId7 => "name-id7",
}),
);
map.insert(
"raw_policy".into(),
json!(match config.photos.raw_policy {
crate::types::RawPolicy::AsIs => "as-is",
crate::types::RawPolicy::PreferRaw => "prefer-raw",
crate::types::RawPolicy::PreferJpeg => "prefer-jpeg",
}),
);
map.insert(
"live_photo_mode".into(),
json!(match config.photos.live_photo_mode {
crate::types::LivePhotoMode::Both => "both",
crate::types::LivePhotoMode::ImageOnly => "image-only",
crate::types::LivePhotoMode::VideoOnly => "video-only",
crate::types::LivePhotoMode::Skip => "skip",
}),
);
map.insert(
"filename_exclusions".into(),
json!(config.download.filename_exclude.len()),
);
map.insert("watch_interval_secs".into(), json!(config.watch.interval));
map.insert("per_transfer".into(), json!(config.retry.max_retries));
map.insert(
"per_asset".into(),
json!(config.retry.max_download_attempts),
);
map.insert(
"legacy_preservation_allow_hardlinks".into(),
json!(config.download.legacy_preservation_allow_hardlinks),
);
map.insert(
"keep_unicode_in_filenames".into(),
json!(config.photos.keep_unicode_in_filenames),
);
map.insert(
"date_filter_present".into(),
json!(
config.filters.skip_created_before.is_some()
|| config.filters.skip_created_after.is_some()
),
);
map.insert(
"recent_limit_present".into(),
json!(config.filters.recent.is_some()),
);
map.insert(
"report_configured".into(),
json!(config.report.json.is_some()),
);
map.insert("strict".into(), json!(config.import.strict));
map.insert("xmp_feature".into(), json!(cfg!(feature = "xmp")));
use crate::selection::{AlbumSelector, SmartFolderSelector};
let (mode, included, excluded) = match &config.filters.selection.albums {
AlbumSelector::None => ("none", 0, 0),
AlbumSelector::All { excluded } => ("all", 0, excluded.len()),
AlbumSelector::Named { included, excluded } => {
("named", included.len(), excluded.len())
}
};
map.insert("album_mode".into(), json!(mode));
map.insert("album_selectors".into(), json!(included));
map.insert("album_exclusions".into(), json!(excluded));
let (mode, included, excluded, sensitive) = match &config.filters.selection.smart_folders {
SmartFolderSelector::None => ("none", 0, 0, false),
SmartFolderSelector::All {
excluded,
include_sensitive,
} => ("all", 0, excluded.len(), *include_sensitive),
SmartFolderSelector::Named { included, excluded } => {
("named", included.len(), excluded.len(), false)
}
};
map.insert("smart_folder_mode".into(), json!(mode));
map.insert("smart_folder_selectors".into(), json!(included));
map.insert("smart_folder_exclusions".into(), json!(excluded));
map.insert("sensitive_folders".into(), json!(sensitive));
map.insert(
"library_selectors".into(),
json!(config.filters.selection.libraries.named.len()),
);
map.insert(
"library_exclusions".into(),
json!(config.filters.selection.libraries.excluded.len()),
);
map.insert(
"primary_library".into(),
json!(config.filters.selection.libraries.primary),
);
map.insert(
"shared_libraries".into(),
json!(config.filters.selection.libraries.shared_all),
);
map.insert("unfiled".into(), json!(config.filters.selection.unfiled));
}
privacy::configuration(&value)
}
fn configuration(toml: Option<&crate::config::TomlConfig>, explicit: bool) -> Value {
let filters = toml.and_then(|t| t.filters.as_ref());
let download = toml.and_then(|t| t.download.as_ref());
let auth = toml.and_then(|t| t.auth.as_ref());
let watch = toml.and_then(|t| t.watch.as_ref());
let mut value = json!({
"config_explicit": explicit,
"data_dir_from_environment": std::env::var_os("KEI_DATA_DIR").is_some(),
"username_from_environment": std::env::var_os("ICLOUD_USERNAME").is_some(),
"data_dir_configured": toml.is_some_and(|t| t.data_dir.is_some()),
"password_command_configured": auth.is_some_and(|a| a.password_command.is_some()),
"password_file_configured": auth.is_some_and(|a| a.password_file.is_some()),
"album_selectors": filters.and_then(|f| f.albums.as_ref()).map_or(0, Vec::len),
"smart_folder_selectors": filters.and_then(|f| f.smart_folders.as_ref()).map_or(0, Vec::len),
"library_selectors": filters.and_then(|f| f.libraries.as_ref()).map_or(0, Vec::len),
"filename_exclusions": filters.and_then(|f| f.filename_exclude.as_ref()).map_or(0, Vec::len),
"unfiled": filters.and_then(|f| f.unfiled).unwrap_or(true),
"date_filter_present": filters.is_some_and(|f| f.skip_created_before.is_some() || f.skip_created_after.is_some()),
"recent_limit_present": filters.is_some_and(|f| f.recent.is_some()),
"threads": download.and_then(|d| d.threads).unwrap_or(3),
"legacy_preservation_allow_hardlinks": download.and_then(|d| d.legacy_preservation_allow_hardlinks).unwrap_or(false),
"watch_interval_secs": watch.and_then(|w| w.interval),
"reconcile_every_n_cycles": watch.and_then(|w| w.reconcile_every_n_cycles),
"report_configured": toml.and_then(|t| t.report.as_ref()).is_some_and(|r| r.json.is_some()),
"notification_configured": toml.and_then(|t| t.notifications.as_ref()).is_some_and(|n| n.script.is_some()),
"server_configured": toml.is_some_and(|t| t.server.is_some()),
"folder_template_custom": download.is_some_and(|d| d.folder_structure.is_some()),
"album_template_custom": download.is_some_and(|d| d.folder_structure_albums.is_some()),
"smart_template_custom": download.is_some_and(|d| d.folder_structure_smart_folders.is_some()),
"xmp_feature": cfg!(feature = "xmp"),
});
// Input enums are already parsed into typed values, but the wire contract
// remains an explicit projection rather than serializing the TOML object.
if let Some(photos) = toml.and_then(|t| t.photos.as_ref())
&& let Ok(v) = serde_json::to_value(photos)
&& let (Some(dst), Some(src)) = (value.as_object_mut(), v.as_object())
{
for (k, v) in src {
dst.insert(k.clone(), v.clone());
}
}
privacy::configuration(&value)
}
pub(crate) async fn export(
args: crate::cli::SupportExportArgs,
raw_config: &str,
) -> anyhow::Result<()> {
let config_path = crate::config::expand_tilde(raw_config);
let config_path = if raw_config == "~/.config/kei/config.toml"
&& !config_path.exists()
&& Path::new("/config/config.toml").is_file()
{
PathBuf::from("/config/config.toml")
} else {
config_path
};
let explicit = raw_config != "~/.config/kei/config.toml" && raw_config != "/config/config.toml";
let output = args.output;
let result =
tokio::task::spawn_blocking(move || collect::export(&config_path, explicit, &output))
.await?;
let (cycles, partial) = result?;
println!(
"Support export saved: {cycles} retained operation/cycle records{}.",
if partial {
"; some evidence unavailable or truncated"
} else {
""
}
);
println!("Review the JSON file locally, then attach it if you choose. Nothing was uploaded.");
Ok(())
}
/// Projects typed causes to fixed diagnostic fields without exposing error text.
pub(crate) fn classify_support_error(operation: &'static str, error: &anyhow::Error) -> Value {
let mut class = "other";
let mut errno = None;
let mut http_status = None;
let mut sqlite_code = None;
for cause in error.chain() {
if let Some(rusqlite::Error::SqliteFailure(code, _)) =
cause.downcast_ref::<rusqlite::Error>()
{
class = "state";
sqlite_code = Some(code.extended_code);
}
if let Some(io) = cause.downcast_ref::<std::io::Error>() {
errno = io.raw_os_error();
class = match io.kind() {
std::io::ErrorKind::PermissionDenied => "permission_denied",
std::io::ErrorKind::NotFound => "not_found",
std::io::ErrorKind::AlreadyExists => "already_exists",
std::io::ErrorKind::Unsupported => "unsupported",
std::io::ErrorKind::OutOfMemory => "out_of_memory",
_ => "io",
};
}
if let Some(download) = cause.downcast_ref::<crate::download::error::DownloadError>() {
match download {
crate::download::error::DownloadError::HttpStatus { status, .. }
| crate::download::error::DownloadError::Http { status, .. } => {
http_status = Some(*status);
}
crate::download::error::DownloadError::Disk(io) => {
errno = io.raw_os_error();
class = "io";
}
crate::download::error::DownloadError::Interrupted { .. } => {
class = "interrupted";
}
_ => {}
}
}
if let Some(icloud) = cause.downcast_ref::<crate::icloud::error::ICloudError>() {
match icloud {
crate::icloud::error::ICloudError::ServiceNotActivated { .. } => {
class = "provider_not_ready"
}
crate::icloud::error::ICloudError::SessionExpired { status } => {
http_status = Some(*status)
}
crate::icloud::error::ICloudError::MisdirectedRequest => http_status = Some(421),
_ => {}
}
}
if let Some(request) = cause.downcast_ref::<reqwest::Error>() {
http_status = request.status().map(|s| s.as_u16());
}
}
if matches!(http_status, Some(401 | 403 | 421)) {
class = "authentication";
} else if matches!(http_status, Some(429 | 503)) {
class = "rate_limited";
} else if http_status == Some(410) {
class = "expired_url";
}
json!({ "operation": operation, "class": class, "errno": errno, "http_status": http_status, "sqlite_code": sqlite_code })
}
#[cfg(test)]
mod tests;
pub(crate) fn download_error_fields(
operation: &'static str,
error: &crate::download::error::DownloadError,
) -> Value {
use crate::download::error::DownloadError;
match error {
DownloadError::HttpStatus { status, .. } | DownloadError::Http { status, .. } => json!({
"operation": operation, "http_status": status,
"class": if error.is_session_expired() { "authentication" } else if error.is_expired_url() { "expired_url" }
else if error.is_rate_limited() { "rate_limited" } else { "request_failed" },
}),
DownloadError::Disk(io) => {
json!({"operation": operation, "class": "io", "errno": io.raw_os_error()})
}
DownloadError::Interrupted { .. } => {
json!({"operation": operation, "class": "interrupted"})
}
DownloadError::ContentLengthMismatch { .. } => {
json!({"operation": operation, "class": "size_mismatch"})
}
DownloadError::InvalidContent { .. } => {
json!({"operation": operation, "class": "invalid_content"})
}
DownloadError::Other(error) => classify_support_error(operation, error),
}
}
pub(crate) fn observe_metadata(path: &Path, kind: &'static str, fields: Value) {
observe_scoped(
"metadata",
Some((&path.to_string_lossy(), "metadata")),
kind,
fields,
);
}
pub(crate) fn metadata_error(
path: &Path,
stage: &'static str,
error: &anyhow::Error,
committed: Option<bool>,
) {
let mut fields = classify_support_error("metadata_capture", error);
if let Some(map) = fields.as_object_mut() {
map.insert("stage".into(), json!(stage));
map.insert("publication_committed".into(), json!(committed));
}
observe_metadata(path, "support_task_error_v1", fields);
}