fix(offline-cache): locate a CMAF bundle under the root the cache is open at
Playback rebuilt the bundle layout from the absolute paths recorded in the row. Those agree with reality until the application directory moves, and iOS moves it: an app's data container is reassigned on reinstall, and can change on restore or migration. Every stored path then names a directory that no longer exists while the bundle sits untouched under the new one, so every download reports "Downloaded copy could not be opened" with the bytes still on disk. Deriving is safe because the index lives inside the root (<root>/index.db), so a row can only ever describe a bundle beneath that same root, and persist_bundle writes nowhere else. Removal and eviction already resolve this way; this brings playback in line with them.
This commit is contained in:
2 files changed
+235
-79
No files matched your search
@@ -236,3 +236,61 @@ fn write_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||
fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn raw_bundle() -> CmafRawBundle {
|
||||
CmafRawBundle {
|
||||
init_bytes: b"init-segment-bytes".to_vec(),
|
||||
segments: vec![b"segment-one".to_vec(), b"segment-two".to_vec()],
|
||||
content_key: [7u8; 16],
|
||||
infos: "infos".to_string(),
|
||||
format_id: 6,
|
||||
sampling_rate: Some(44100),
|
||||
bit_depth: Some(16),
|
||||
n_segments: 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bundle is found by deriving its location from the root the cache is
|
||||
/// open at, so an application directory that moves underneath it costs
|
||||
/// nothing. iOS reassigns an app's data container on reinstall, which is
|
||||
/// what makes this more than theoretical: every absolute path recorded at
|
||||
/// download time names a directory that no longer exists, while the bundle
|
||||
/// itself is untouched.
|
||||
#[test]
|
||||
fn a_bundle_is_found_after_its_root_moves() {
|
||||
let old_root = tempfile::tempdir().unwrap();
|
||||
let (written, _) = persist_bundle(old_root.path(), 42, &raw_bundle()).unwrap();
|
||||
|
||||
// What a reinstall does: same bytes, same layout, different prefix.
|
||||
let new_parent = tempfile::tempdir().unwrap();
|
||||
let new_root = new_parent.path().join("audio");
|
||||
std::fs::rename(old_root.path(), &new_root).unwrap();
|
||||
assert!(
|
||||
!written.segments_path.exists(),
|
||||
"the recorded path is stale"
|
||||
);
|
||||
|
||||
let loaded = read_bundle(&BundleLayout::new(&new_root, 42)).unwrap();
|
||||
|
||||
assert_eq!(loaded.init_bytes, b"init-segment-bytes");
|
||||
assert_eq!(loaded.segments.len(), 2);
|
||||
assert_eq!(loaded.segments[1], b"segment-two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_layout_is_the_same_one_persist_wrote_to() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let (written, _) = persist_bundle(root.path(), 7, &raw_bundle()).unwrap();
|
||||
|
||||
// Playback derives rather than reading the row back, so the two must
|
||||
// agree for every track: this is what makes deriving safe.
|
||||
let derived = BundleLayout::new(root.path(), 7);
|
||||
assert_eq!(derived.init_path, written.init_path);
|
||||
assert_eq!(derived.segments_path, written.segments_path);
|
||||
assert_eq!(derived.manifest_path, written.manifest_path);
|
||||
}
|
||||
}
|
||||
@@ -72,80 +72,7 @@ pub fn load_cmaf_bundle(
|
||||
row: &CmafBundleRow,
|
||||
offline_root_path: &Path,
|
||||
) -> Option<Vec<u8>> {
|
||||
if row.cache_format != 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let init_path = row.init_path.as_ref().or_else(|| {
|
||||
log::warn!(
|
||||
"[OfflineCache/Play] Track {} cache_format=2 but init_path is null",
|
||||
track_id
|
||||
);
|
||||
None
|
||||
})?;
|
||||
let content_key_wrapped = row.content_key_wrapped.as_ref().or_else(|| {
|
||||
log::warn!(
|
||||
"[OfflineCache/Play] Track {} cache_format=2 but content_key_wrapped is null",
|
||||
track_id
|
||||
);
|
||||
None
|
||||
})?;
|
||||
|
||||
let segments_path = std::path::PathBuf::from(&row.segments_path);
|
||||
let track_dir = segments_path.parent()?.to_path_buf();
|
||||
let layout = BundleLayout {
|
||||
track_dir,
|
||||
init_path: std::path::PathBuf::from(init_path),
|
||||
segments_path: segments_path.clone(),
|
||||
manifest_path: segments_path.with_file_name("manifest.json"),
|
||||
};
|
||||
|
||||
let loaded = match cmaf_store::read_bundle(&layout) {
|
||||
Ok(lb) => lb,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[OfflineCache/Play] Track {} failed to read CMAF bundle: {}",
|
||||
track_id,
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let vault = match secret_vault::get_or_init(offline_root_path) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[OfflineCache/Play] Track {} SecretBox init failed: {}",
|
||||
track_id,
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let unwrapped = match vault.unwrap(content_key_wrapped) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[OfflineCache/Play] Track {} content_key unwrap failed: {}",
|
||||
track_id,
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if unwrapped.len() != 16 {
|
||||
log::warn!(
|
||||
"[OfflineCache/Play] Track {} unwrapped content_key wrong size ({} bytes)",
|
||||
track_id,
|
||||
unwrapped.len()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let mut content_key = [0u8; 16];
|
||||
content_key.copy_from_slice(&unwrapped);
|
||||
|
||||
match loaded.decrypt_to_flac(&content_key) {
|
||||
match load_bundle(track_id, row, offline_root_path) {
|
||||
Ok(flac_bytes) => {
|
||||
log::info!(
|
||||
"[OfflineCache/Play] Track {} unwrapped + decrypted ({:.2} MB FLAC)",
|
||||
@@ -154,13 +81,184 @@ pub fn load_cmaf_bundle(
|
||||
);
|
||||
Some(flac_bytes)
|
||||
}
|
||||
// A v1 row is not a failure, it is simply not this function's business.
|
||||
Err(BundleLoadError::NotCmaf) => None,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[OfflineCache/Play] Track {} CMAF decrypt failed: {}",
|
||||
track_id,
|
||||
e
|
||||
);
|
||||
log::warn!("[OfflineCache/Play] Track {} {}", track_id, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a bundle could not be turned back into FLAC.
|
||||
///
|
||||
/// The public entry point collapses all of these to `None`, because to a caller
|
||||
/// they mean the same thing: fall through to the next tier. They are kept apart
|
||||
/// **so a test can tell which stage failed** — without that, an implementation
|
||||
/// that looked for the bundle in the wrong place would be indistinguishable from
|
||||
/// one that simply could not decrypt it (see
|
||||
/// `resolution_looks_under_the_current_root_not_the_recorded_path`).
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum BundleLoadError {
|
||||
/// A v1 (plain FLAC) row. The caller reads `file_path` itself.
|
||||
NotCmaf,
|
||||
/// The row was written before the bundle finished landing.
|
||||
IncompleteRow(&'static str),
|
||||
Read(String),
|
||||
Vault(String),
|
||||
Unwrap(String),
|
||||
KeySize(usize),
|
||||
Decrypt(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for BundleLoadError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NotCmaf => write!(f, "not a CMAF bundle"),
|
||||
Self::IncompleteRow(col) => write!(f, "cache_format=2 but {col} is null"),
|
||||
Self::Read(e) => write!(f, "failed to read CMAF bundle: {e}"),
|
||||
Self::Vault(e) => write!(f, "SecretBox init failed: {e}"),
|
||||
Self::Unwrap(e) => write!(f, "content_key unwrap failed: {e}"),
|
||||
Self::KeySize(n) => write!(f, "unwrapped content_key wrong size ({n} bytes)"),
|
||||
Self::Decrypt(e) => write!(f, "CMAF decrypt failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_bundle(
|
||||
track_id: u64,
|
||||
row: &CmafBundleRow,
|
||||
offline_root_path: &Path,
|
||||
) -> Result<Vec<u8>, BundleLoadError> {
|
||||
if row.cache_format != 2 {
|
||||
return Err(BundleLoadError::NotCmaf);
|
||||
}
|
||||
// The column is not read for its value — the layout below is derived — but a
|
||||
// null here means the row was written before the bundle was complete, and
|
||||
// that is the one thing it still distinguishes.
|
||||
if row.init_path.is_none() {
|
||||
return Err(BundleLoadError::IncompleteRow("init_path"));
|
||||
}
|
||||
let content_key_wrapped = row
|
||||
.content_key_wrapped
|
||||
.as_ref()
|
||||
.ok_or(BundleLoadError::IncompleteRow("content_key_wrapped"))?;
|
||||
|
||||
// Derived from the root the cache is open at, not from the absolute paths
|
||||
// the row recorded. The two agree until the application directory moves,
|
||||
// and then only the recorded ones are wrong: iOS reassigns an app's data
|
||||
// container on reinstall (and can on restore or migration), which leaves
|
||||
// every stored path naming a directory that no longer exists while the
|
||||
// bundle itself sits untouched under the new one.
|
||||
//
|
||||
// Deriving is safe because the index lives *inside* the root
|
||||
// (`<root>/index.db`), so a row can only ever describe a bundle under that
|
||||
// same root, and `persist_bundle` writes nowhere else. Removal and eviction
|
||||
// already resolve this way — see `maintenance::remove_album_cached_tracks`.
|
||||
let layout = BundleLayout::new(offline_root_path, track_id);
|
||||
if layout.segments_path != std::path::Path::new(&row.segments_path) {
|
||||
log::info!(
|
||||
"[OfflineCache/Play] Track {} bundle resolved under the current root; \
|
||||
the recorded path predates a move",
|
||||
track_id
|
||||
);
|
||||
}
|
||||
|
||||
let loaded = cmaf_store::read_bundle(&layout).map_err(BundleLoadError::Read)?;
|
||||
|
||||
let vault = secret_vault::get_or_init(offline_root_path)
|
||||
.map_err(|e| BundleLoadError::Vault(e.to_string()))?;
|
||||
let unwrapped = vault
|
||||
.unwrap(content_key_wrapped)
|
||||
.map_err(|e| BundleLoadError::Unwrap(e.to_string()))?;
|
||||
if unwrapped.len() != 16 {
|
||||
return Err(BundleLoadError::KeySize(unwrapped.len()));
|
||||
}
|
||||
let mut content_key = [0u8; 16];
|
||||
content_key.copy_from_slice(&unwrapped);
|
||||
|
||||
loaded
|
||||
.decrypt_to_flac(&content_key)
|
||||
.map_err(BundleLoadError::Decrypt)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn row_recording(segments_path: &str) -> CmafBundleRow {
|
||||
CmafBundleRow {
|
||||
cache_format: 2,
|
||||
segments_path: segments_path.to_string(),
|
||||
init_path: Some("/gone/init.mp4".to_string()),
|
||||
// Deliberately not a real wrapped key: the point of these tests is
|
||||
// which stage is *reached*, and the key stage is past the read.
|
||||
content_key_wrapped: Some(vec![0u8; 8]),
|
||||
infos_wrapped: None,
|
||||
format_id: Some(6),
|
||||
n_segments: Some(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// The regression guard for the container-move bug.
|
||||
///
|
||||
/// The row records a path that no longer exists — which is exactly what iOS
|
||||
/// leaves behind when it reassigns an app's data container. With nothing on
|
||||
/// disk either way this fails at the read, and `read_bundle` names the path
|
||||
/// it tried, so the message is the assertion: it must be the root the cache
|
||||
/// is open at, never the one the row recorded.
|
||||
///
|
||||
/// Deliberately stops at the read rather than persisting a bundle and
|
||||
/// checking it got further. Getting further means reaching the secret vault,
|
||||
/// and that opens the OS keyring under the *production* service name — a
|
||||
/// unit test has no business touching a developer's login keychain.
|
||||
#[test]
|
||||
fn resolution_looks_under_the_current_root_not_the_recorded_path() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let row = row_recording("/recorded-elsewhere/tracks-cmaf/42/segments.bin");
|
||||
|
||||
let err = load_bundle(42, &row, root.path()).unwrap_err();
|
||||
|
||||
let BundleLoadError::Read(message) = &err else {
|
||||
panic!("expected a read failure with nothing on disk, got {err:?}");
|
||||
};
|
||||
assert!(
|
||||
message.contains(root.path().to_str().unwrap()),
|
||||
"the read should have been attempted under the current root: {message}"
|
||||
);
|
||||
assert!(
|
||||
!message.contains("/recorded-elsewhere/"),
|
||||
"the recorded path was used, which is the bug this guards: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v1_row_is_not_this_functions_business() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut row = row_recording("/gone/x.flac");
|
||||
row.cache_format = 1;
|
||||
|
||||
assert_eq!(
|
||||
load_bundle(1, &row, root.path()).unwrap_err(),
|
||||
BundleLoadError::NotCmaf
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_written_before_the_bundle_landed_is_reported_as_incomplete() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
||||
let mut row = row_recording("/gone/tracks-cmaf/42/segments.bin");
|
||||
row.init_path = None;
|
||||
assert_eq!(
|
||||
load_bundle(42, &row, root.path()).unwrap_err(),
|
||||
BundleLoadError::IncompleteRow("init_path")
|
||||
);
|
||||
|
||||
let mut row = row_recording("/gone/tracks-cmaf/42/segments.bin");
|
||||
row.content_key_wrapped = None;
|
||||
assert_eq!(
|
||||
load_bundle(42, &row, root.path()).unwrap_err(),
|
||||
BundleLoadError::IncompleteRow("content_key_wrapped")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user