steamroom-cli 0.2.0

Command-line tool for downloading Steam depot content
Documentation
#[derive(Debug, thiserror::Error)]
pub enum CliError {
    #[error("{}", display_steam_error(.0))]
    Steam(#[from] steamroom::error::Error),

    #[error("{0}")]
    Io(#[from] std::io::Error),

    #[error("cryptography error: {0}")]
    Crypto(#[from] steamroom::error::CryptoError),

    #[error("internal task error: {0}")]
    Join(#[from] tokio::task::JoinError),

    #[error("{}", display_manifest_error(.0))]
    Manifest(#[from] steamroom::error::ManifestError),

    #[error("chunk processing failed: {0}")]
    Chunk(#[from] steamroom::depot::chunk::ChunkError),

    #[error("failed to decode server response: {0}")]
    Protobuf(#[from] prost::DecodeError),

    #[error("invalid regex pattern: {0}")]
    Regex(#[from] regex::Error),

    #[error("JSON error: {0}")]
    Json(#[from] serde_json::Error),

    #[error("{}", display_http_error(.0))]
    Http(#[from] reqwest::Error),

    #[error("failed to parse KeyValue data: {0}")]
    Kv(#[from] steamroom::types::key_value::TextKvError),

    #[error("Steam returned no product info for app {0} (does the app exist?)")]
    NoProductInfo(u32),

    #[error("app {0} has no metadata")]
    NoKvData(u32),

    #[error("no depots found in app info")]
    NoDepots,

    #[error("depot {0} was not found in the app info")]
    DepotNotFound(u32),

    #[error("no manifest found for depot {depot} on branch \"{branch}\"")]
    ManifestNotFound { depot: u32, branch: String },

    #[error("the manifest ID is not a valid number")]
    InvalidManifestId,

    #[error("no cached decryption key for depot {0} in config.vdf")]
    NoLocalKey(u32),

    #[error("Steam installation not found")]
    SteamNotFound,

    #[error("{}", display_login_error(.0))]
    Login(#[from] steamroom_client::login::LoginError),

    #[error(
        "authentication requires an interactive terminal. Re-run on a TTY, \
         supply --password / STEAM_PASS for credentials login, or use a \
         valid saved refresh token."
    )]
    InteractiveAuthRequired,

    #[error("Steam returned no CDN servers")]
    NoCdnServers,

    #[error(
        "--use-daemon: {0} are not supported via the daemon; pass them to --daemon at launch instead"
    )]
    DaemonRejectedFlag(&'static str),

    #[error("--priority is only valid with --use-daemon")]
    PriorityWithoutDaemon,

    #[error("--detach is only valid with --use-daemon")]
    DetachWithoutDaemon,

    #[error("--daemon and --use-daemon are mutually exclusive")]
    DaemonModeConflict,

    #[error(
        "daemon RPC: incompatible wire-protocol version (peer={peer}, ours={ours}); restart the daemon"
    )]
    ProtocolVersionMismatch { peer: u16, ours: u16 },

    #[error("daemon RPC: frame exceeds {limit_bytes} byte cap (got {len_bytes})")]
    FrameTooLarge { len_bytes: u64, limit_bytes: u64 },

    #[error("daemon RPC: malformed frame: {0}")]
    MalformedFrame(String),

    #[error("daemon RPC: socket closed before frame complete")]
    SocketClosed,

    #[error("operation cancelled")]
    Cancelled,

    #[error("a steamroom daemon is already running on this socket")]
    DaemonAlreadyRunning,

    #[error("no daemon running on this socket; start one with `steamroom daemon start`")]
    NoDaemonRunning,

    #[error("daemon returned error: {0}")]
    DaemonError(String),
}

impl From<steamroom::error::ConnectionError> for CliError {
    fn from(e: steamroom::error::ConnectionError) -> Self {
        Self::Steam(steamroom::error::Error::Connection(e))
    }
}

fn display_login_error(e: &steamroom_client::login::LoginError) -> String {
    use steamroom_client::login::LoginError;
    match e {
        LoginError::InvalidPassword => "invalid password".into(),
        LoginError::InvalidGuardCode => "two-factor code rejected".into(),
        LoginError::LogonFailed(r) => format!("login failed: {}", eresult_message(r)),
        LoginError::Transport(inner) => display_steam_error(inner),
        LoginError::MissingField(f) => format!("Steam response missing field: {f}"),
        LoginError::NoCmServers => "could not find any Steam CM servers to connect to".into(),
        _ => e.to_string(),
    }
}

fn display_steam_error(e: &steamroom::error::Error) -> String {
    use steamroom::error::ConnectionError;
    use steamroom::error::Error;

    match e {
        Error::Connection(ConnectionError::LogonFailed(r)) => {
            format!("login failed: {}", eresult_message(r))
        }
        Error::Connection(ConnectionError::ServiceMethodFailed(r)) => {
            format!("Steam API call failed: {}", eresult_message(r))
        }
        Error::Connection(ConnectionError::DepotAccessDenied(depot)) => {
            format!("access denied for depot {depot} (do you own this app? try logging in with -u)")
        }
        Error::Connection(ConnectionError::Disconnected) => "disconnected from Steam".into(),
        Error::Connection(ConnectionError::DnsResolutionFailed) => {
            "DNS resolution failed (check your network connection)".into()
        }
        Error::Connection(ConnectionError::EncryptionFailed) => {
            "failed to establish encrypted connection to Steam".into()
        }
        Error::Connection(ConnectionError::MissingField(field)) => {
            format!("Steam response is missing required field: {field}")
        }
        Error::CdnStatus { status, .. } => {
            use reqwest::StatusCode;
            let code = status.as_u16();
            if *status == StatusCode::UNAUTHORIZED || *status == StatusCode::FORBIDDEN {
                format!("CDN access denied (HTTP {code})")
            } else if *status == StatusCode::NOT_FOUND {
                "content not found on CDN (HTTP 404)".into()
            } else if *status == StatusCode::TOO_MANY_REQUESTS {
                "rate limited by CDN (HTTP 429), retries exhausted".into()
            } else if status.is_server_error() {
                format!("CDN server error (HTTP {code})")
            } else {
                format!("CDN returned HTTP {code}")
            }
        }
        other => other.to_string(),
    }
}

fn display_manifest_error(e: &steamroom::error::ManifestError) -> String {
    use steamroom::error::ManifestError;
    match e {
        ManifestError::MissingSection => "manifest is missing required data sections".into(),
        ManifestError::ChecksumMismatch { .. } => {
            "manifest checksum mismatch (corrupt download?)".into()
        }
        ManifestError::DecryptFailed(_) => {
            "failed to decrypt manifest filenames (wrong depot key?)".into()
        }
        other => format!("manifest error: {other}"),
    }
}

fn display_http_error(e: &reqwest::Error) -> String {
    if e.is_connect() {
        format!("connection failed (check your network): {e}")
    } else if e.is_timeout() {
        format!("request timed out: {e}")
    } else {
        format!("HTTP error: {e}")
    }
}

fn eresult_message(r: &steamroom::enums::EResultError) -> &'static str {
    use steamroom::enums::EResultError;
    match r {
        EResultError::InvalidPassword => "invalid password",
        EResultError::AccessDenied => "access denied",
        EResultError::Banned => "account is banned",
        EResultError::AccountNotFound => "account not found",
        EResultError::InvalidSteamID => "invalid Steam ID",
        EResultError::ServiceUnavailable => "Steam service is temporarily unavailable",
        EResultError::Timeout => "request timed out",
        EResultError::LimitExceeded => "rate limit exceeded, try again later",
        EResultError::Expired => "session expired, please log in again",
        EResultError::InsufficientPrivilege => "insufficient privileges",
        EResultError::NotLoggedOn => "not logged in",
        EResultError::Busy => "Steam is busy, try again later",
        EResultError::Revoked => "access has been revoked",
        EResultError::NoConnection => "no connection to Steam",
        _ => "unknown error",
    }
}