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
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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())
Expand All @@ -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())
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,15 @@ 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
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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) }
}
Expand Down
Loading