diff --git a/app/src/main/kotlin/dev/injun/remotesync/data/local/DirectFileLocalStorage.kt b/app/src/main/kotlin/dev/injun/remotesync/data/local/DirectFileLocalStorage.kt index b4980d6..3b69327 100644 --- a/app/src/main/kotlin/dev/injun/remotesync/data/local/DirectFileLocalStorage.kt +++ b/app/src/main/kotlin/dev/injun/remotesync/data/local/DirectFileLocalStorage.kt @@ -1,7 +1,7 @@ package dev.injun.remotesync.data.local -import dev.injun.remotesync.core.model.FileMeta import dev.injun.remotesync.core.model.Snapshot +import dev.injun.remotesync.core.port.ContentHash import dev.injun.remotesync.core.port.RawEntry import dev.injun.remotesync.core.port.SnapshotBuilder import dev.injun.remotesync.core.port.Storage @@ -11,7 +11,6 @@ import java.io.FileOutputStream import java.io.IOException import java.nio.file.Files import java.nio.file.StandardCopyOption -import java.security.MessageDigest import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow @@ -118,11 +117,6 @@ class DirectFileLocalStorage(private val root: File) : Storage { Unit } - override suspend fun stat(path: String): FileMeta? = withContext(Dispatchers.IO) { - val f = resolve(path) - if (!f.isFile) null else FileMeta(f.length(), f.lastModified(), hashFile(f)) - } - override suspend fun probe(path: String): RawEntry? = withContext(Dispatchers.IO) { val f = resolve(path) if (!f.isFile) null else RawEntry(path, f.length(), f.lastModified()) @@ -146,16 +140,5 @@ class DirectFileLocalStorage(private val root: File) : Storage { return f } - private fun hashFile(f: File): String { - val md = MessageDigest.getInstance("SHA-256") - f.inputStream().use { ins -> - val buf = ByteArray(1 shl 16) - while (true) { - val n = ins.read(buf) - if (n < 0) break - md.update(buf, 0, n) - } - } - return md.digest().joinToString("") { "%02x".format(it) } - } + private fun hashFile(f: File): String = ContentHash.sha256Hex(f.source()) } diff --git a/app/src/main/kotlin/dev/injun/remotesync/data/remote/SmbRemoteStorage.kt b/app/src/main/kotlin/dev/injun/remotesync/data/remote/SmbRemoteStorage.kt index 4805199..2f3b63a 100644 --- a/app/src/main/kotlin/dev/injun/remotesync/data/remote/SmbRemoteStorage.kt +++ b/app/src/main/kotlin/dev/injun/remotesync/data/remote/SmbRemoteStorage.kt @@ -15,8 +15,8 @@ import com.hierynomus.smbj.auth.AuthenticationContext import com.hierynomus.smbj.connection.Connection import com.hierynomus.smbj.session.Session import com.hierynomus.smbj.share.DiskShare -import dev.injun.remotesync.core.model.FileMeta import dev.injun.remotesync.core.model.Snapshot +import dev.injun.remotesync.core.port.ContentHash import dev.injun.remotesync.core.port.RawEntry import dev.injun.remotesync.core.port.SnapshotBuilder import dev.injun.remotesync.core.port.Storage @@ -24,7 +24,6 @@ import dev.injun.remotesync.core.port.TempFiles import dev.injun.remotesync.sync.RemoteStorage import dev.injun.remotesync.sync.SmbConfig import java.io.IOException -import java.security.MessageDigest import java.util.EnumSet import java.util.concurrent.ExecutionException import java.util.concurrent.TimeUnit @@ -42,6 +41,7 @@ import kotlinx.coroutines.withContext import okio.Buffer import okio.Source import okio.buffer +import okio.source /** * [Storage] over an SMB2/3 share via smbj (v1 remote). Writes are atomic (temp name @@ -225,21 +225,6 @@ class SmbRemoteStorage( ).use { renameReplacing(disk, it, smbPath(to)) } } - override suspend fun stat(path: String): FileMeta? = withContext(Dispatchers.IO) { - val disk = share() - val p = smbPath(path) - if (!disk.fileExists(p)) { - null - } else { - val info = disk.getFileInformation(p) - FileMeta( - info.standardInformation.endOfFile, - info.basicInformation.lastWriteTime.toEpochMillis(), - hashRemote(disk, path), - ) - } - } - override suspend fun probe(path: String): RawEntry? = withContext(Dispatchers.IO) { val disk = share() val p = smbPath(path) @@ -292,8 +277,7 @@ class SmbRemoteStorage( } } - private fun hashRemote(disk: DiskShare, path: String): String { - val md = MessageDigest.getInstance("SHA-256") + private fun hashRemote(disk: DiskShare, path: String): String = disk.openFile( smbPath(path), EnumSet.of(AccessMask.GENERIC_READ), @@ -302,17 +286,11 @@ class SmbRemoteStorage( SMB2CreateDisposition.FILE_OPEN, null, ).use { file -> - file.getInputStream().use { ins -> - val buf = ByteArray(1 shl 16) - while (true) { - val n = ins.read(buf) - if (n < 0) break - md.update(buf, 0, n) - } - } + // Close the InputStream (via the okio Source) before the handle: unlike + // read(), which must keep the handle to return a live stream, hashing + // consumes the whole file here, so both can be released. + ContentHash.sha256Hex(file.getInputStream().source()) } - return md.digest().joinToString("") { "%02x".format(it) } - } /** Convert a '/'-relative sync path to a share-relative, '\'-separated SMB path. */ private fun smbPath(rel: String): String { diff --git a/app/src/test/kotlin/dev/injun/remotesync/data/remote/SmbRemoteStorageIntegrationTest.kt b/app/src/test/kotlin/dev/injun/remotesync/data/remote/SmbRemoteStorageIntegrationTest.kt index 173071c..5ebcee6 100644 --- a/app/src/test/kotlin/dev/injun/remotesync/data/remote/SmbRemoteStorageIntegrationTest.kt +++ b/app/src/test/kotlin/dev/injun/remotesync/data/remote/SmbRemoteStorageIntegrationTest.kt @@ -76,11 +76,11 @@ class SmbRemoteStorageIntegrationTest { // move smb.move("vault/db.kdbx", "vault/renamed.kdbx") assertEquals("secret-v2", readText(smb, "vault/renamed.kdbx")) - assertNull(smb.stat("vault/db.kdbx")) + assertNull(smb.probe("vault/db.kdbx")) // delete smb.delete("vault/renamed.kdbx") - assertNull(smb.stat("vault/renamed.kdbx")) + assertNull(smb.probe("vault/renamed.kdbx")) } } diff --git a/core-sync/src/main/kotlin/dev/injun/remotesync/core/model/FileMeta.kt b/core-sync/src/main/kotlin/dev/injun/remotesync/core/model/FileMeta.kt index 739d1b3..85f4d97 100644 --- a/core-sync/src/main/kotlin/dev/injun/remotesync/core/model/FileMeta.kt +++ b/core-sync/src/main/kotlin/dev/injun/remotesync/core/model/FileMeta.kt @@ -7,6 +7,11 @@ package dev.injun.remotesync.core.model data class FileMeta( val size: Long, val mtimeMillis: Long, + /** + * The file's identity: SHA-256 of its raw bytes as lowercase hex. Local and remote + * hashes are compared directly, so every backend must produce this the same way — + * see the content-hash contract on `Storage.scan` / `ContentHash.sha256Hex`. + */ val contentHash: String, /** * True when this stat was observed while its mtime still sat inside the coarse-mtime diff --git a/core-sync/src/main/kotlin/dev/injun/remotesync/core/port/ContentHash.kt b/core-sync/src/main/kotlin/dev/injun/remotesync/core/port/ContentHash.kt new file mode 100644 index 0000000..c555d0e --- /dev/null +++ b/core-sync/src/main/kotlin/dev/injun/remotesync/core/port/ContentHash.kt @@ -0,0 +1,26 @@ +package dev.injun.remotesync.core.port + +import okio.HashingSource +import okio.Source +import okio.blackholeSink +import okio.buffer + +/** + * The single content-hash algorithm every [Storage] must use: SHA-256 over the file's + * raw bytes, rendered as lowercase hex. The engine compares hashes produced by + * different backends directly (local against remote), so the algorithm and its + * encoding are a cross-implementation contract, not a private detail. A backend that + * hashed any other way would compile and pass its own tests while silently breaking + * every cross-side comparison — converged files would look like conflicts, or + * different files could collide. Sharing one implementation here is what keeps a new + * backend from diverging. + */ +object ContentHash { + + /** Stream [source] through SHA-256 and return the lowercase-hex digest; closes [source]. */ + fun sha256Hex(source: Source): String { + val hashing = HashingSource.sha256(source) + hashing.buffer().use { it.readAll(blackholeSink()) } + return hashing.hash.hex() + } +} diff --git a/core-sync/src/main/kotlin/dev/injun/remotesync/core/port/Storage.kt b/core-sync/src/main/kotlin/dev/injun/remotesync/core/port/Storage.kt index a992218..d57e145 100644 --- a/core-sync/src/main/kotlin/dev/injun/remotesync/core/port/Storage.kt +++ b/core-sync/src/main/kotlin/dev/injun/remotesync/core/port/Storage.kt @@ -19,6 +19,10 @@ interface Storage { /** * Build a hashed snapshot. Implementations may reuse a hash from [hint] when a * file's size+mtime are unchanged, avoiding re-hashing unmodified files. + * + * Every entry's [FileMeta.contentHash] MUST be produced by [ContentHash.sha256Hex] + * — the engine compares hashes from different backends directly, so hashing any + * other way silently breaks cross-side content comparison. */ suspend fun scan(hint: Snapshot = Snapshot.EMPTY): Snapshot @@ -40,12 +44,10 @@ interface Storage { /** Atomically rename [from] to [to], replacing [to] if present. */ suspend fun move(from: String, to: String) - suspend fun stat(path: String): FileMeta? - /** - * Cheap size/mtime lookup with NO content hash ([stat] on a remote reads the whole - * file to hash it); null if [path] is not a regular file. The executor uses this to - * re-verify a target immediately before a destructive operation. + * Cheap size/mtime lookup with NO content hash; null if [path] is not a regular + * file. The executor uses this to re-verify a target immediately before a + * destructive operation. */ suspend fun probe(path: String): RawEntry? diff --git a/core-sync/src/test/kotlin/dev/injun/remotesync/core/exec/SyncExecutorTest.kt b/core-sync/src/test/kotlin/dev/injun/remotesync/core/exec/SyncExecutorTest.kt index 83fa494..f2bb4a4 100644 --- a/core-sync/src/test/kotlin/dev/injun/remotesync/core/exec/SyncExecutorTest.kt +++ b/core-sync/src/test/kotlin/dev/injun/remotesync/core/exec/SyncExecutorTest.kt @@ -298,7 +298,7 @@ class SyncExecutorTest { assertTrue(result.failures.isEmpty() && result.skippedPaths.isEmpty()) val after = requireNotNull(anc.load()["a.txt"]) assertNotEquals(before.local.mtimeMillis, after.local.mtimeMillis) - assertEquals(requireNotNull(local.stat("a.txt")).mtimeMillis, after.local.mtimeMillis) + assertEquals(requireNotNull(local.probe("a.txt")).mtimeMillis, after.local.mtimeMillis) assertEquals(before.local.contentHash, after.local.contentHash) assertEquals(before.remote, after.remote) } diff --git a/core-sync/src/test/kotlin/dev/injun/remotesync/core/fake/Fakes.kt b/core-sync/src/test/kotlin/dev/injun/remotesync/core/fake/Fakes.kt index f335efe..cc466a0 100644 --- a/core-sync/src/test/kotlin/dev/injun/remotesync/core/fake/Fakes.kt +++ b/core-sync/src/test/kotlin/dev/injun/remotesync/core/fake/Fakes.kt @@ -1,6 +1,5 @@ package dev.injun.remotesync.core.fake -import dev.injun.remotesync.core.model.FileMeta import dev.injun.remotesync.core.model.Snapshot import dev.injun.remotesync.core.port.AncestorRecord import dev.injun.remotesync.core.port.AncestorStore @@ -144,9 +143,6 @@ class InMemoryStorage(private val fault: FaultController? = null) : Storage { files.remove(from) } - override suspend fun stat(path: String): FileMeta? = - files[path]?.let { FileMeta(it.bytes.size.toLong(), it.mtime, sha256(it.bytes)) } - override suspend fun probe(path: String): RawEntry? = files[path]?.let { RawEntry(path, it.bytes.size.toLong(), it.mtime) } }