Repository: openSUSE/osc

Issue:
osc rq co xxxx fails when xxxx comes from a scmsync'ed project
**Describe the bug**
This has showed up on botmaster, as the container was (finally) updated and includes a more recent osc version

I can easily reproduce this locally with:
```
> osc rq co 1238483
A    devel:BCI:Tumbleweed
A    devel:BCI:Tumbleweed/stunnel-image
BuildService API error: File '_scmsync.obsinfo' was not found in devel:BCI:Tumbleweed/stunnel-image, rev=9ad8ca5fbec1e95f0bef600e31739908
```

The 'source checker' bot thus does not get a full, valid check out to perform the checks on

**Versions**
- Operating system: Tumbleweed, Snapshot 20250119
- Packages: osc-1.11.1-1.1.noarch

**To Reproduce**
Steps to reproduce the behavior:
1. osc rq co 1238483
2. The checkout fails on 'missing files'

**Expected behavior**
Checkout succeeds and gives a working source tree

Discussion:
@dmach: @adrianschroeter is this even a valid package?

There's `<scmsync>https://github.com/SUSE/bci-dockerfile-generator?subdir=stunnel-image#Tumbleweed</scmsync>` in _meta but OBS holds regular files in the internal SCM:
```
$ osc api /source/devel:BCI:Tumbleweed/stunnel-image

<directory name="stunnel-image" rev="1" vrev="1" srcmd5="9ad8ca5fbec1e95f0bef600e31739908">
  <entry name="Dockerfile" md5="2c0aeb5aefdd3967ffcb4bfea425ee83" size="2728" mtime="1737036204"/>
  <entry name="README.md" md5="00cd08f5c1ef403de70f19b3e6615512" size="3305" mtime="1733475088"/>
  <entry name="_service" md5="aa998380964397144fd9537fcd05ad46" size="347" mtime="1733241096"/>
  <entry name="entrypoint.sh" md5="b36a991a48df462a9a4df0dcafa29c7a" size="688" mtime="1733405860"/>
  <entry name="stunnel-image.changes" md5="67e509930c6008055c5e8b102263983d" size="176" mtime="1737109946"/>
  <entry name="stunnel.conf" md5="d9cb220bdaf13805954bef5f81410973" size="47" mtime="1733391031"/>
</directory>
```

When `<scmsync>` is found in _meta, do we expect `_scmsync.obsinfo` to be available or is the file optional?@dcermak: > When `<scmsync>` is found in _meta, do we expect `_scmsync.obsinfo` to be available or is the file optional?

It is optional as off: https://github.com/openSUSE/obs-scm-bridge/commit/1aaa7c55edd9da1124f4bda596c2bdf1e84bbb77@dmach: Fix submitted to openSUSE:Factory: https://build.opensuse.org/request/show/1242880

Solution patch:
diff --git a/osc/conf.py b/osc/conf.py
index d4ea41e3..40d825d8 100644
--- a/osc/conf.py
+++ b/osc/conf.py
@@ -1941,6 +1941,7 @@ def get_config(override_conffile=None,
 
             ini_key = field.extra.get("ini_key", name)
             known_ini_keys.add(ini_key)
+            known_ini_keys.add(name)
 
             # iterate through aliases and store the value of the the first env that matches OSC_HOST_{ALIAS}_{NAME}
             env_value = None
@@ -1993,6 +1994,7 @@ def get_config(override_conffile=None,
     for name, field in config.__fields__.items():
         ini_key = field.extra.get("ini_key", name)
         known_ini_keys.add(ini_key)
+        known_ini_keys.add(name)
         env_key = f"OSC_{name.upper()}"
 
         # priority: env, overrides, config
diff --git a/osc/core.py b/osc/core.py
index e64ee139..11845e69 100644
--- a/osc/core.py
+++ b/osc/core.py
@@ -31,7 +31,7 @@ from http.client import IncompleteRead
 from io import StringIO
 from pathlib import Path
 from typing import Optional, Dict, Union, List, Iterable
-from urllib.parse import urlsplit, urlunsplit, urlparse, quote, urlencode, unquote
+from urllib.parse import parse_qs, urlsplit, urlunsplit, urlparse, quote, urlencode, unquote
 from urllib.error import HTTPError
 from xml.etree import ElementTree as ET
 
@@ -2790,11 +2790,14 @@ def sha256_dgst(file):
     return s.hexdigest()
 
 
-def binary(s):
-    """return ``True`` if a string is binary data using diff's heuristic"""
-    if s and bytes('\0', "utf-8") in s[:4096]:
-        return True
-    return False
+def binary(data: bytes):
+    """
+    Return ``True`` if ``data`` is binary data.
+
+    We're using heuristics according to OBS: src/backend/BSSrcServer/filediff - look for "diff binary detection"
+    """
+    binary_chars = re.findall(b"[\x00-\0x07\x0e-\x1f]", data)
+    return len(binary_chars) * 40 > len(data)
 
 
 def binary_file(fn):
@@ -3165,14 +3168,16 @@ def checkout_package(
     if not native_obs_package and scmsync_element is not None and scmsync_element.text is not None:
         directory = make_dir(apiurl, project, package, pathname, prj_dir, conf.config['do_package_tracking'], outdir)
 
-        if revision is not None:
+        scm_url = scmsync_element.text
+        fetch_obsinfo = "noobsinfo" not in parse_qs(urlparse(scm_url).query)
+
+        if revision is not None and fetch_obsinfo:
             # search for the git sha sum based on the OBS DISTURL package source revision
             # we need also take into account that the url was different at that point of time
             from .obs_api.scmsync_obsinfo import ScmsyncObsinfo
             scmsync_obsinfo = ScmsyncObsinfo.from_api(apiurl, project, package, rev=revision)
             scm_url = f"{scmsync_obsinfo.url}#{scmsync_obsinfo.revision}"
 
-        scm_url = scmsync_element.text
         run_obs_scm_bridge(url=scm_url, target_dir=directory)
 
         Package.init_package(apiurl, project, package, directory, size_limit, meta, progress_obj, scm_url)


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
index 6b822879..1303645c 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -1,5 +1,9 @@
+import os
+import shutil
+import tempfile
 import unittest
 
+from osc.core import binary_file
 from osc.core import makeurl
 from osc.core import UrlQueryArray
 from osc.core import parseRevisionOption
@@ -138,5 +142,52 @@ class TestMakeurl(unittest.TestCase):
             self.assertEqual(url, f"https://example.com/api/v1?{encoded_char}={encoded_char}")
 
 
+class TestBinaryFile(unittest.TestCase):
+    def setUp(self):
+        self.tmpdir = tempfile.mkdtemp(prefix="osc_test_")
+
+    def tearDown(self):
+        shutil.rmtree(self.tmpdir)
+
+    def test_text(self):
+        path = os.path.join(self.tmpdir, "text")
+        with open(path, "w") as f:
+            f.write(1000 * "a")
+        self.assertFalse(binary_file(path))
+
+    def test_text_with_binary_chars(self):
+        path = os.path.join(self.tmpdir, "binary")
+        with open(path, "wb") as f:
+            f.write(1000 * b"a")
+            f.write(b"\0")
+        self.assertFalse(binary_file(path))
+
+        with open(path, "wb") as f:
+            f.write(4096 * b"a")
+            f.write(b"\0")
+        self.assertFalse(binary_file(path))
+
+    def test_binary(self):
+        path = os.path.join(self.tmpdir, "binary")
+
+        # sufficient control chars in first 4k
+        with open(path, "wb") as f:
+            f.write(1000 * b"a")
+            f.write(26 * b"\0")
+        self.assertTrue(binary_file(path))
+
+        # sufficient control chars in first 4k
+        with open(path, "wb") as f:
+            f.write(3993 * b"a")
+            f.write(103 * b"\0")
+        self.assertTrue(binary_file(path))
+
+        # detected as text because we're reading only first 4k characters
+        with open(path, "wb") as f:
+            f.write(4096 * b"a")
+            f.write(1000 * b"\0")
+        self.assertFalse(binary_file(path))
+
+
 if __name__ == "__main__":
     unittest.main()